From 3c5fabc20b6ea8742306ba77898925695a4f1b5f Mon Sep 17 00:00:00 2001 From: Thomas Gran Date: Fri, 8 Aug 2025 12:03:56 +0200 Subject: [PATCH 01/40] feat: carpooling skeleton --- .../ext/carpooling/CarpoolingRepository.java | 3 + .../ext/carpooling/CarpoolingService.java | 10 + .../configure/CarpoolingModule.java | 34 +++ .../internal/DefaultCarpoolingRepository.java | 3 + .../internal/DefaultCarpoolingService.java | 46 +++ .../carpooling/model/CarpoolTransitLeg.java | 279 ++++++++++++++++++ .../model/CarpoolTransitLegBuilder.java | 90 ++++++ .../updater/SiriETCarpoolingUpdater.java | 110 +++++++ .../SiriETCarpoolingUpdaterParameters.java | 25 ++ .../framework/application/OTPFeature.java | 1 + .../opentripplanner/model/plan/Itinerary.java | 2 +- .../routing/algorithm/RoutingWorker.java | 22 +- .../api/OtpServerRequestContext.java | 14 + .../config/routerconfig/UpdatersConfig.java | 8 + .../SiriETCarpoolingUpdaterConfig.java | 55 ++++ .../ConstructApplicationFactory.java | 7 + .../configure/ConstructApplicationModule.java | 4 +- .../server/DefaultServerRequestContext.java | 13 +- .../updater/UpdatersParameters.java | 3 + .../configure/SiriETCarpoolingModule.java | 54 ++++ .../configure/UpdaterConfigurator.java | 5 + .../opentripplanner/TestServerContext.java | 1 + .../transit/speed_test/SpeedTest.java | 2 - doc/user/Configuration.md | 1 + 24 files changed, 785 insertions(+), 7 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java create mode 100644 application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java create mode 100644 application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java new file mode 100644 index 00000000000..b6b8f4b1377 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java @@ -0,0 +1,3 @@ +package org.opentripplanner.ext.carpooling; + +public interface CarpoolingRepository {} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java new file mode 100644 index 00000000000..1243292bdac --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -0,0 +1,10 @@ +package org.opentripplanner.ext.carpooling; + +import java.util.List; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.error.RoutingValidationException; + +public interface CarpoolingService { + List route(RouteRequest request) throws RoutingValidationException; +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java new file mode 100644 index 00000000000..178f8431c8a --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -0,0 +1,34 @@ +package org.opentripplanner.ext.carpooling.configure; + +import dagger.Module; +import dagger.Provides; +import jakarta.inject.Singleton; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.framework.application.OTPFeature; + +/** + * TODO CARPOOLING + */ +@Module +public class CarpoolingModule { + + @Provides + @Singleton + public CarpoolingRepository provideCarpoolingRepository() { + if (OTPFeature.CarPooling.isOff()) { + return null; + } + // TODO CARPOOLING + return null; + } + + @Provides + public static CarpoolingService provideCarpoolingService(CarpoolingRepository repository) { + if (OTPFeature.CarPooling.isOff()) { + return null; + } + // TODO CARPOOLING + return null; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java new file mode 100644 index 00000000000..16766237638 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java @@ -0,0 +1,3 @@ +package org.opentripplanner.ext.carpooling.internal; + +public class DefaultCarpoolingRepository {} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java new file mode 100644 index 00000000000..5d55f02750e --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java @@ -0,0 +1,46 @@ +package org.opentripplanner.ext.carpooling.internal; + +import java.util.List; +import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.error.RoutingValidationException; + +public class DefaultCarpoolingService implements CarpoolingService { + + /** + * TERMINOLOGY + * - Boarding and alighting area stops + * + * + * ALGORITHM OUTLINE + * + *
+   *   DIRECT_DISTANCE = SphericalDistanceLibrary.fastDistance(fromLocation, toLocation)
+   *   // 3000m is about 45 minutes of walking
+   *   MAX_WALK_DISTANCE = max(DIRECT_DISTANCE, 3000m)
+   *   MAX_COST = MAX_WALK_DISTANCE * walkReluctance + DIRECT_DISTANCE - MAX_WALK_DISTANCE
+   *
+   * Search for access / egress candidates (AreaStops) using
+   * - accessDistance = SphericalDistanceLibrary.fastDistance(fromLocation, stop.center);
+   * - Drop candidates where accessDistance greater then MAX_WALK_DISTANCE and is not within time constraints
+   * - egressDistance = SphericalDistanceLibrary.fastDistance(toLocation, stop.center);
+   * - Drop candidates where (accessDistance + egressDistance) greater then MAX_WALK_DISTANCE (no time check)
+   * - Sort candidates on estimated cost, where we use direct distance instead of actual distance
+   *
+   * FOR EACH CANDIDATE (C)
+   * - Use AStar to find the actual distance for:
+   *   - access path
+   *   - transit path
+   *   - egress path
+   * - Drop candidates where (access+carpool+egress) cost > MAX_COST
+   * [- Abort when no more optimal results can be obtained (pri2)]
+   *
+   * Create Itineraries for the top 3 results and return
+   * 
+ */ + @Override + public List route(RouteRequest request) throws RoutingValidationException { + return List.of(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java new file mode 100644 index 00000000000..f0695ee2643 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java @@ -0,0 +1,279 @@ +package org.opentripplanner.ext.carpooling.model; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nullable; +import org.locationtech.jts.geom.LineString; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.model.PickDrop; +import org.opentripplanner.model.fare.FareProductUse; +import org.opentripplanner.model.plan.Emission; +import org.opentripplanner.model.plan.Leg; +import org.opentripplanner.model.plan.Place; +import org.opentripplanner.model.plan.TransitLeg; +import org.opentripplanner.model.plan.leg.LegCallTime; +import org.opentripplanner.model.plan.leg.StopArrival; +import org.opentripplanner.routing.alertpatch.TransitAlert; +import org.opentripplanner.transit.model.basic.Accessibility; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.framework.AbstractTransitEntity; +import org.opentripplanner.transit.model.network.Route; +import org.opentripplanner.transit.model.organization.Agency; +import org.opentripplanner.transit.model.organization.Operator; +import org.opentripplanner.transit.model.timetable.Trip; +import org.opentripplanner.transit.model.timetable.booking.BookingInfo; +import org.opentripplanner.utils.tostring.ToStringBuilder; + +/** + * One leg of a trip -- that is, a temporally continuous piece of the journey that takes place on a + * particular vehicle, which is running on flexible trip, i.e. not using fixed schedule and stops. + */ +public class CarpoolTransitLeg implements TransitLeg { + + private final ZonedDateTime startTime; + + private final ZonedDateTime endTime; + + private final Set transitAlerts; + + private final int generalizedCost; + + private final Emission emissionPerPerson; + + private final List fareProducts; + + CarpoolTransitLeg(CarpoolTransitLegBuilder builder) { + this.startTime = Objects.requireNonNull(builder.startTime()); + this.endTime = Objects.requireNonNull(builder.endTime()); + this.generalizedCost = builder.generalizedCost(); + this.transitAlerts = Set.copyOf(builder.alerts()); + this.fareProducts = List.copyOf(builder.fareProducts()); + this.emissionPerPerson = builder.emissionPerPerson(); + } + + /** + * Return an empty builder for {@link CarpoolTransitLeg}. + */ + public static CarpoolTransitLegBuilder of() { + return new CarpoolTransitLegBuilder(); + } + + public CarpoolTransitLegBuilder copyOf() { + return new CarpoolTransitLegBuilder(this); + } + + @Override + public Agency agency() { + return trip().getRoute().getAgency(); + } + + @Override + @Nullable + public Operator operator() { + return trip().getOperator(); + } + + @Override + public Route route() { + return trip().getRoute(); + } + + @Override + public Trip trip() { + // TODO CARPOOLING + return null; + } + + @Override + public Accessibility tripWheelchairAccessibility() { + // TODO CARPOOLING + return null; + } + + @Override + public LegCallTime start() { + return LegCallTime.ofStatic(startTime); + } + + @Override + public LegCallTime end() { + return LegCallTime.ofStatic(endTime); + } + + @Override + public TransitMode mode() { + return trip().getMode(); + } + + @Override + public ZonedDateTime startTime() { + return startTime; + } + + @Override + public ZonedDateTime endTime() { + return endTime; + } + + @Override + public boolean isFlexibleTrip() { + return true; + } + + @Override + public double distanceMeters() { + // TODO CARPOOLING + return 999_999.0; + } + + @Override + public Integer routeType() { + return trip().getRoute().getGtfsType(); + } + + @Override + public I18NString headsign() { + return trip().getHeadsign(); + } + + @Override + public LocalDate serviceDate() { + // TODO CARPOOLING + return null; + } + + @Override + public Place from() { + // TODO CARPOOLING + return null; + } + + @Override + public Place to() { + // TODO CARPOOLING + return null; + } + + @Override + public List listIntermediateStops() { + return List.of(); + } + + @Override + public LineString legGeometry() { + // TODO CARPOOLING + return null; + } + + @Override + public Set listTransitAlerts() { + return transitAlerts; + } + + @Override + public TransitLeg decorateWithAlerts(Set alerts) { + return copyOf().withAlerts(alerts).build(); + } + + @Override + public TransitLeg decorateWithFareProducts(List fares) { + return copyOf().withFareProducts(fares).build(); + } + + @Override + public PickDrop boardRule() { + // TODO CARPOOLING + return null; + } + + @Override + public PickDrop alightRule() { + // TODO CARPOOLING + return null; + } + + @Override + public BookingInfo dropOffBookingInfo() { + // TODO CARPOOLING + return null; + } + + @Override + public BookingInfo pickupBookingInfo() { + // TODO CARPOOLING + return null; + } + + @Override + public Integer boardStopPosInPattern() { + // TODO CARPOOLING + return 0; + } + + @Override + public Integer alightStopPosInPattern() { + // TODO CARPOOLING + return 1; + } + + @Override + public int generalizedCost() { + return generalizedCost; + } + + @Override + public Leg withTimeShift(Duration duration) { + return copyOf() + .withStartTime(startTime.plus(duration)) + .withEndTime(endTime.plus(duration)) + .build(); + } + + @Nullable + @Override + public Emission emissionPerPerson() { + return emissionPerPerson; + } + + @Nullable + @Override + public Leg withEmissionPerPerson(Emission emissionPerPerson) { + return copyOf().withEmissionPerPerson(emissionPerPerson).build(); + } + + @Override + public List fareProducts() { + return fareProducts; + } + + /** + * Should be used for debug logging only + */ + @Override + public String toString() { + return ToStringBuilder.of(CarpoolTransitLeg.class) + .addObj("from", from()) + .addObj("to", to()) + .addTime("startTime", startTime) + .addTime("endTime", endTime) + .addNum("distance", distanceMeters(), "m") + .addNum("cost", generalizedCost) + .addObjOp("agencyId", agency(), AbstractTransitEntity::getId) + .addObjOp("routeId", route(), AbstractTransitEntity::getId) + .addObjOp("tripId", trip(), AbstractTransitEntity::getId) + .addObj("serviceDate", serviceDate()) + .addObj("legGeometry", legGeometry()) + .addCol("transitAlerts", transitAlerts) + .addNum("boardingStopIndex", boardStopPosInPattern()) + .addNum("alightStopIndex", alightStopPosInPattern()) + .addEnum("boardRule", boardRule()) + .addEnum("alightRule", alightRule()) + .addObj("pickupBookingInfo", pickupBookingInfo()) + .addObj("dropOffBookingInfo", dropOffBookingInfo()) + .toString(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java new file mode 100644 index 00000000000..b59e4d78373 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java @@ -0,0 +1,90 @@ +package org.opentripplanner.ext.carpooling.model; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.opentripplanner.model.fare.FareProductUse; +import org.opentripplanner.model.plan.Emission; +import org.opentripplanner.routing.alertpatch.TransitAlert; + +public class CarpoolTransitLegBuilder { + + private ZonedDateTime startTime; + private ZonedDateTime endTime; + private int generalizedCost; + private Set transitAlerts = new HashSet<>(); + private List fareProducts = new ArrayList<>(); + private Emission emissionPerPerson; + + CarpoolTransitLegBuilder() {} + + CarpoolTransitLegBuilder(CarpoolTransitLeg original) { + startTime = original.startTime(); + endTime = original.endTime(); + generalizedCost = original.generalizedCost(); + transitAlerts = original.listTransitAlerts(); + fareProducts = original.fareProducts(); + emissionPerPerson = original.emissionPerPerson(); + } + + public CarpoolTransitLegBuilder withStartTime(ZonedDateTime startTime) { + this.startTime = startTime; + return this; + } + + public ZonedDateTime startTime() { + return startTime; + } + + public CarpoolTransitLegBuilder withEndTime(ZonedDateTime endTime) { + this.endTime = endTime; + return this; + } + + public ZonedDateTime endTime() { + return endTime; + } + + public CarpoolTransitLegBuilder withGeneralizedCost(int generalizedCost) { + this.generalizedCost = generalizedCost; + return this; + } + + public int generalizedCost() { + return generalizedCost; + } + + public CarpoolTransitLegBuilder withAlerts(Collection alerts) { + this.transitAlerts = Set.copyOf(alerts); + return this; + } + + public Set alerts() { + return transitAlerts; + } + + public CarpoolTransitLegBuilder withFareProducts(List allUses) { + this.fareProducts = List.copyOf(allUses); + return this; + } + + public List fareProducts() { + return fareProducts; + } + + public CarpoolTransitLegBuilder withEmissionPerPerson(Emission emissionPerPerson) { + this.emissionPerPerson = emissionPerPerson; + return this; + } + + public Emission emissionPerPerson() { + return emissionPerPerson; + } + + public CarpoolTransitLeg build() { + return new CarpoolTransitLeg(this); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java new file mode 100644 index 00000000000..8e0dedaca2f --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -0,0 +1,110 @@ +package org.opentripplanner.ext.carpooling.updater; + +import java.util.List; +import java.util.function.Consumer; +import org.opentripplanner.updater.spi.PollingGraphUpdater; +import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; +import org.opentripplanner.updater.spi.ResultLogger; +import org.opentripplanner.updater.spi.UpdateResult; +import org.opentripplanner.updater.trip.UrlUpdaterParameters; +import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter; +import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableHandler; +import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; +import org.opentripplanner.utils.tostring.ToStringBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uk.org.siri.siri21.EstimatedTimetableDeliveryStructure; +import uk.org.siri.siri21.ServiceDelivery; + +/** + * Update OTP stop timetables from some a Siri-ET HTTP sources. + */ +public class SiriETCarpoolingUpdater extends PollingGraphUpdater { + + private static final Logger LOG = LoggerFactory.getLogger(SiriETCarpoolingUpdater.class); + /** + * Update streamer + */ + private final EstimatedTimetableSource updateSource; + + /** + * Feed id that is used for the trip ids in the TripUpdates + */ + private final String feedId; + + private final EstimatedTimetableHandler estimatedTimetableHandler; + + private final Consumer metricsConsumer; + + public SiriETCarpoolingUpdater( + Parameters config, + SiriRealTimeTripUpdateAdapter adapter, + EstimatedTimetableSource source, + Consumer metricsConsumer + ) { + super(config); + this.feedId = config.feedId(); + + this.updateSource = source; + + this.blockReadinessUntilInitialized = config.blockReadinessUntilInitialized(); + + LOG.info("Creating SIRI-ET updater running every {}: {}", pollingPeriod(), updateSource); + + estimatedTimetableHandler = new EstimatedTimetableHandler( + adapter, + config.fuzzyTripMatching(), + feedId + ); + + this.metricsConsumer = metricsConsumer; + } + + /** + * Repeatedly makes blocking calls to an UpdateStreamer to retrieve new stop time updates, and + * applies those updates to the graph. + */ + @Override + public void runPolling() { + boolean moreData = false; + do { + var updates = updateSource.getUpdates(); + if (updates.isPresent()) { + var incrementality = updateSource.incrementalityOfLastUpdates(); + ServiceDelivery serviceDelivery = updates.get().getServiceDelivery(); + moreData = Boolean.TRUE.equals(serviceDelivery.isMoreData()); + // Mark this updater as primed after last page of updates. Copy moreData into a final + // primitive, because the object moreData persists across iterations. + final boolean markPrimed = !moreData; + List etds = + serviceDelivery.getEstimatedTimetableDeliveries(); + if (etds != null) { + updateGraph(context -> { + var result = estimatedTimetableHandler.applyUpdate(etds, incrementality, context); + ResultLogger.logUpdateResult(feedId, "siri-et", result); + metricsConsumer.accept(result); + if (markPrimed) { + primed = true; + } + }); + } + } + } while (moreData); + } + + @Override + public String toString() { + return ToStringBuilder.of(SiriETCarpoolingUpdater.class) + .addStr("source", updateSource.toString()) + .addDuration("frequency", pollingPeriod()) + .toString(); + } + + public interface Parameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { + String url(); + + boolean blockReadinessUntilInitialized(); + + boolean fuzzyTripMatching(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java new file mode 100644 index 00000000000..4ada97e5426 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java @@ -0,0 +1,25 @@ +package org.opentripplanner.ext.carpooling.updater; + +import java.time.Duration; +import org.opentripplanner.updater.spi.HttpHeaders; +import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; +import org.opentripplanner.updater.trip.siri.updater.SiriETUpdater; + +public record SiriETCarpoolingUpdaterParameters( + String configRef, + String feedId, + boolean blockReadinessUntilInitialized, + String url, + Duration frequency, + String requestorRef, + Duration timeout, + Duration previewInterval, + boolean fuzzyTripMatching, + HttpHeaders httpRequestHeaders, + boolean producerMetrics +) + implements SiriETUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters { + public SiriETHttpTripUpdateSource.Parameters sourceParameters() { + return this; + } +} diff --git a/application/src/main/java/org/opentripplanner/framework/application/OTPFeature.java b/application/src/main/java/org/opentripplanner/framework/application/OTPFeature.java index ad66999b5d6..416735436a4 100644 --- a/application/src/main/java/org/opentripplanner/framework/application/OTPFeature.java +++ b/application/src/main/java/org/opentripplanner/framework/application/OTPFeature.java @@ -102,6 +102,7 @@ public enum OTPFeature { "Make all polling updaters wait for graph updates to complete before finishing. " + "If this is not enabled, the updaters will finish after submitting the task to update the graph." ), + CarPooling(false, true, "Enable the carpooling sandbox module."), Emission(false, true, "Enable the emission sandbox module."), EmpiricalDelay(false, true, "Enable empirical delay sandbox module."), DataOverlay( diff --git a/application/src/main/java/org/opentripplanner/model/plan/Itinerary.java b/application/src/main/java/org/opentripplanner/model/plan/Itinerary.java index 8c4b1d7f076..b94c6cc5a55 100644 --- a/application/src/main/java/org/opentripplanner/model/plan/Itinerary.java +++ b/application/src/main/java/org/opentripplanner/model/plan/Itinerary.java @@ -59,7 +59,7 @@ public class Itinerary implements ItinerarySortKey { private final Duration totalWalkDuration; private final boolean walkOnly; - /* RENATL */ + /* RENTAL */ private final boolean arrivedAtDestinationWithRentedVehicle; /* WAIT */ diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java index 988d11710d0..7dc32d20f58 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java @@ -104,13 +104,14 @@ public RoutingResponse route() { var r1 = CompletableFuture.supplyAsync(this::routeDirectStreet); var r2 = CompletableFuture.supplyAsync(this::routeDirectFlex); var r3 = CompletableFuture.supplyAsync(this::routeTransit); + var r4 = CompletableFuture.supplyAsync(this::routeCarpooling); - result.merge(r1.join(), r2.join(), r3.join()); + result.merge(r1.join(), r2.join(), r3.join(), r4.join()); } catch (CompletionException e) { RoutingValidationException.unwrapAndRethrowCompletionException(e); } } else { - result.merge(routeDirectStreet(), routeDirectFlex(), routeTransit()); + result.merge(routeDirectStreet(), routeDirectFlex(), routeTransit(), routeCarpooling()); } // Set C2 value for Street and FLEX if transit-group-priority is used @@ -256,6 +257,23 @@ private RoutingResult routeDirectFlex() { } } + private RoutingResult routeCarpooling() { + if (OTPFeature.CarPooling.isOff()) { + return RoutingResult.ok(List.of()); + } + // TODO CARPOOLING Add carpooling timer + // debugTimingAggregator.startedCarpoolingRouter(); + try { + // TODO CARPOOLING + return RoutingResult.ok(serverContext.carpoolingService().route(request)); + } catch (RoutingValidationException e) { + return RoutingResult.failed(e.getRoutingErrors()); + } finally { + // TODO CARPOOLING Add + //debugTimingAggregator.finishedCarpoolingRouter(); + } + } + private RoutingResult routeTransit() { debugTimingAggregator.startedTransitRouting(); try { diff --git a/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java b/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java index 82cce821d4a..d1af8491519 100644 --- a/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java +++ b/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java @@ -8,6 +8,7 @@ import org.opentripplanner.apis.gtfs.GtfsApiParameters; import org.opentripplanner.apis.transmodel.TransmodelAPIParameters; import org.opentripplanner.astar.spi.TraverseVisitor; +import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.dataoverlay.routing.DataOverlayContext; import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService; import org.opentripplanner.ext.flex.FlexParameters; @@ -146,6 +147,7 @@ default GraphFinder graphFinder() { /* Sandbox modules */ @Nullable +<<<<<<< HEAD default List listExtensionRequestContexts(RouteRequest request) { var list = new ArrayList(); if (OTPFeature.DataOverlay.isOn()) { @@ -157,6 +159,18 @@ default List listExtensionRequestContexts(RouteRequest ); } return list; +======= + CarpoolingService carpoolingService(); + + @Nullable + default DataOverlayContext dataOverlayContext(RouteRequest request) { + return OTPFeature.DataOverlay.isOnElseNull(() -> + new DataOverlayContext( + graph().dataOverlayParameterBindings, + request.preferences().system().dataOverlay() + ) + ); +>>>>>>> 5bec82e6a3 (feat: carpooling skeleton) } @Nullable diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java index bc53db8b35a..5c6bd0a5beb 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.function.BiFunction; import javax.annotation.Nullable; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureETUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureSXUpdaterParameters; import org.opentripplanner.ext.vehiclerentalservicedirectory.VehicleRentalServiceDirectoryFetcher; @@ -31,6 +32,7 @@ import org.opentripplanner.standalone.config.routerconfig.updaters.GtfsRealtimeAlertsUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.MqttGtfsRealtimeUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.PollingTripUpdaterConfig; +import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETCarpoolingUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETGooglePubsubUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETLiteUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETUpdaterConfig; @@ -177,6 +179,11 @@ public List getSiriETUpdaterParameters() { return getParameters(SIRI_ET_UPDATER); } + @Override + public List getSiriETCarpoolingUpdaterParameters() { + return getParameters(Type.SIRI_ET_CARPOOLING_UPDATER); + } + @Override public List getSiriETGooglePubsubUpdaterParameters() { return getParameters(SIRI_ET_GOOGLE_PUBSUB_UPDATER); @@ -233,6 +240,7 @@ public enum Type { REAL_TIME_ALERTS(GtfsRealtimeAlertsUpdaterConfig::create), VEHICLE_POSITIONS(VehiclePositionsUpdaterConfig::create), SIRI_ET_UPDATER(SiriETUpdaterConfig::create), + SIRI_ET_CARPOOLING_UPDATER(SiriETCarpoolingUpdaterConfig::create), SIRI_ET_LITE(SiriETLiteUpdaterConfig::create), SIRI_ET_GOOGLE_PUBSUB_UPDATER(SiriETGooglePubsubUpdaterConfig::create), SIRI_SX_UPDATER(SiriSXUpdaterConfig::create), diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java new file mode 100644 index 00000000000..e558b51f1a4 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java @@ -0,0 +1,55 @@ +package org.opentripplanner.standalone.config.routerconfig.updaters; + +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_0; +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_3; +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; + +import java.time.Duration; +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; +import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; + +public class SiriETCarpoolingUpdaterConfig { + + public static SiriETUpdaterParameters create(String configRef, NodeAdapter c) { + return new SiriETUpdaterParameters( + configRef, + c.of("feedId").since(V2_0).summary("The ID of the feed to apply the updates to.").asString(), + c + .of("blockReadinessUntilInitialized") + .since(V2_0) + .summary( + "Whether catching up with the updates should block the readiness check from returning a 'ready' result." + ) + .asBoolean(false), + c + .of("url") + .since(V2_0) + .summary("The URL to send the HTTP requests to.") + .description(SiriSXUpdaterConfig.URL_DESCRIPTION) + .asString(), + c + .of("frequency") + .since(V2_0) + .summary("How often the updates should be retrieved.") + .asDuration(Duration.ofMinutes(1)), + c.of("requestorRef").since(V2_0).summary("The requester reference.").asString(null), + c + .of("timeout") + .since(V2_0) + .summary("The HTTP timeout to download the updates.") + .asDuration(Duration.ofSeconds(15)), + c.of("previewInterval").since(V2_0).summary("TODO").asDuration(null), + c + .of("fuzzyTripMatching") + .since(V2_0) + .summary("If the fuzzy trip matcher should be used to match trips.") + .asBoolean(false), + HttpHeadersConfig.headers(c, V2_3), + c + .of("producerMetrics") + .since(V2_7) + .summary("If failure, success, and warning metrics should be collected per producer.") + .asBoolean(false) + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java index b60725b52fb..ce080169417 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java @@ -9,6 +9,8 @@ import org.opentripplanner.apis.gtfs.configure.SchemaModule; import org.opentripplanner.apis.transmodel.configure.TransmodelSchema; import org.opentripplanner.apis.transmodel.configure.TransmodelSchemaModule; +import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.carpooling.configure.CarpoolingModule; import org.opentripplanner.ext.emission.EmissionRepository; import org.opentripplanner.ext.emission.configure.EmissionServiceModule; import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayRepository; @@ -64,6 +66,7 @@ @Singleton @Component( modules = { + CarpoolingModule.class, ConfigModule.class, ConstructApplicationModule.class, EmissionServiceModule.class, @@ -87,6 +90,7 @@ WorldEnvelopeServiceModule.class, } ) + public interface ConstructApplicationFactory { ConfigModel config(); RaptorConfig raptorConfig(); @@ -104,6 +108,9 @@ public interface ConstructApplicationFactory { TimetableSnapshotManager timetableSnapshotManager(); DataImportIssueSummary dataImportIssueSummary(); + @Nullable + CarpoolingService carpoolingService(); + @Nullable EmissionRepository emissionRepository(); diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java index 50191eddad0..216cc50e007 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java @@ -10,7 +10,7 @@ import org.opentripplanner.apis.gtfs.configure.GtfsSchema; import org.opentripplanner.apis.transmodel.configure.TransmodelSchema; import org.opentripplanner.astar.spi.TraverseVisitor; -import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService; +import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.geocoder.LuceneIndex; import org.opentripplanner.ext.interactivelauncher.api.LauncherRequestDecorator; import org.opentripplanner.ext.ridehailing.RideHailingService; @@ -54,6 +54,7 @@ OtpServerRequestContext providesServerContext( VehicleParkingService vehicleParkingService, List rideHailingServices, ViaCoordinateTransferFactory viaTransferResolver, + @Nullable CarpoolingService carpoolingService, @Nullable StopConsolidationService stopConsolidationService, StreetLimitationParametersService streetLimitationParametersService, @Nullable TraverseVisitor traverseVisitor, @@ -97,6 +98,7 @@ OtpServerRequestContext providesServerContext( viaTransferResolver, worldEnvelopeService, // Optional Sandbox services + carpoolingService, emissionItineraryDecorator, empiricalDelayService, luceneIndex, diff --git a/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java b/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java index c36a6496d5d..b35d967b70d 100644 --- a/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java +++ b/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java @@ -9,7 +9,7 @@ import org.opentripplanner.apis.transmodel.TransmodelAPIParameters; import org.opentripplanner.apis.transmodel.configure.TransmodelSchema; import org.opentripplanner.astar.spi.TraverseVisitor; -import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService; +import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.flex.FlexParameters; import org.opentripplanner.ext.geocoder.LuceneIndex; import org.opentripplanner.ext.ridehailing.RideHailingService; @@ -66,6 +66,9 @@ public class DefaultServerRequestContext implements OtpServerRequestContext { /* Optional fields */ + @Nullable + private final CarpoolingService carpoolingService; + @Nullable private final ItineraryDecorator emissionItineraryDecorator; @@ -128,6 +131,7 @@ public DefaultServerRequestContext( VertexLinker vertexLinker, ViaCoordinateTransferFactory viaTransferResolver, WorldEnvelopeService worldEnvelopeService, + @Nullable CarpoolingService carpoolingService, @Nullable ItineraryDecorator emissionItineraryDecorator, @Nullable EmpiricalDelayService empiricalDelayService, @Nullable LuceneIndex luceneIndex, @@ -161,6 +165,7 @@ public DefaultServerRequestContext( this.worldEnvelopeService = worldEnvelopeService; // Optional fields + this.carpoolingService = carpoolingService; this.emissionItineraryDecorator = emissionItineraryDecorator; this.empiricalDelayService = empiricalDelayService; this.luceneIndex = luceneIndex; @@ -298,6 +303,12 @@ public TransmodelAPIParameters transmodelAPIParameters() { return transmodelAPIParameters; } + @Nullable + @Override + public CarpoolingService carpoolingService() { + return carpoolingService; + } + @Nullable @Override public LuceneIndex lucenceIndex() { diff --git a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java index fee4ec396af..7449e8a96b8 100644 --- a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java @@ -1,6 +1,7 @@ package org.opentripplanner.updater; import java.util.List; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureETUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureSXUpdaterParameters; import org.opentripplanner.ext.vehiclerentalservicedirectory.api.VehicleRentalServiceDirectoryFetcherParameters; @@ -46,4 +47,6 @@ public interface UpdatersParameters { List getSiriAzureETUpdaterParameters(); List getSiriAzureSXUpdaterParameters(); + + List getSiriETCarpoolingUpdaterParameters(); } diff --git a/application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java b/application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java new file mode 100644 index 00000000000..a98b8ffc664 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java @@ -0,0 +1,54 @@ +package org.opentripplanner.updater.configure; + +import java.util.function.Consumer; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; +import org.opentripplanner.updater.spi.UpdateResult; +import org.opentripplanner.updater.support.siri.SiriFileLoader; +import org.opentripplanner.updater.support.siri.SiriLoader; +import org.opentripplanner.updater.trip.metrics.TripUpdateMetrics; +import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter; +import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; + +public class SiriETCarpoolingModule { + + public static SiriETCarpoolingUpdater createSiriETCarpoolingUpdater( + SiriETCarpoolingUpdaterParameters params, + SiriRealTimeTripUpdateAdapter adapter + ) { + return null; + //return new SiriETCarpoolingUpdater(params, adapter, createSource(params), createMetricsConsumer(params)); + } + + private static EstimatedTimetableSource createSource(SiriETCarpoolingUpdater.Parameters params) { + return null; + /** + return new new SiriETHttpTripUpdateSource( + params.sourceParameters(), + createLoader(params) + ); + */ + } + + private static SiriLoader createLoader(SiriETCarpoolingUpdater.Parameters params) { + // Load real-time updates from a file. + if (SiriFileLoader.matchesUrl(params.url())) { + return new SiriFileLoader(params.url()); + } + return null; + /** + return new SiriHttpLoader( + params.url(), + params.timeout(), + params.httpRequestHeaders(), + params.previewInterval() + ); + */ + } + + private static Consumer createMetricsConsumer( + SiriETCarpoolingUpdater.Parameters params + ) { + return TripUpdateMetrics.streaming(params); + } +} diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index 079b4ff8e0e..033cd266272 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -185,6 +185,11 @@ private List createUpdatersFromConfig() { for (var configItem : updatersParameters.getSiriETUpdaterParameters()) { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); } + for (var configItem : updatersParameters.getSiriETCarpoolingUpdaterParameters()) { + updaters.add( + SiriETCarpoolingModule.createSiriETCarpoolingUpdater(configItem, provideSiriAdapter()) + ); + } for (var configItem : updatersParameters.getSiriETLiteUpdaterParameters()) { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); } diff --git a/application/src/test/java/org/opentripplanner/TestServerContext.java b/application/src/test/java/org/opentripplanner/TestServerContext.java index b9d5d02238a..ee3b3b01d93 100644 --- a/application/src/test/java/org/opentripplanner/TestServerContext.java +++ b/application/src/test/java/org/opentripplanner/TestServerContext.java @@ -115,6 +115,7 @@ public static OtpServerRequestContext createServerContext( createVertexLinker(graph), createViaTransferResolver(graph, transitService), createWorldEnvelopeService(), + null, createEmissionsItineraryDecorator(), null, null, diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index 4168d204dbd..1477107f774 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -140,8 +140,6 @@ public SpeedTest( null, null, null, - null, - null, null ); // Creating raptor transit data should be integrated into the TimetableRepository, but for now diff --git a/doc/user/Configuration.md b/doc/user/Configuration.md index 74e1648c090..e1da2613d06 100644 --- a/doc/user/Configuration.md +++ b/doc/user/Configuration.md @@ -241,6 +241,7 @@ Here is a list of all features which can be toggled on/off and their default val | `ActuatorAPI` | Endpoint for actuators (service health status). | | ✓️ | | `AsyncGraphQLFetchers` | Whether the @async annotation in the GraphQL schema should lead to the fetch being executed asynchronously. This allows batch or alias queries to run in parallel at the cost of consuming extra threads. | | | | `WaitForGraphUpdateInPollingUpdaters` | Make all polling updaters wait for graph updates to complete before finishing. If this is not enabled, the updaters will finish after submitting the task to update the graph. | ✓️ | | +| `CarPooling` | Enable the carpooling sandbox module. | | ✓️ | | `Emission` | Enable the emission sandbox module. | | ✓️ | | `EmpiricalDelay` | Enable empirical delay sandbox module. | | ✓️ | | `DataOverlay` | Enable usage of data overlay when calculating costs for the street network. | | ✓️ | From cc39b34cb895e441c36e30d4b2a40cc93bd30c34 Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 11 Aug 2025 15:03:45 +0200 Subject: [PATCH 02/40] Adds basics of trip and leg contructs for Carpooling, along with a rudimentary search. Next I want to implement actual A* functionality. --- .../ext/carpooling/CarpoolingRepository.java | 29 +- .../ext/carpooling/CarpoolingService.java | 3 +- .../configure/CarpoolingModule.java | 22 +- .../data/KristiansandCarpoolingData.java | 197 +++++++++ .../internal/DefaultCarpoolingRepository.java | 92 +++- .../internal/DefaultCarpoolingService.java | 409 +++++++++++++++++- .../model/CarpoolItineraryCandidate.java | 9 + ...CarpoolTransitLeg.java => CarpoolLeg.java} | 256 +++++++++-- ...LegBuilder.java => CarpoolLegBuilder.java} | 68 ++- .../ext/carpooling/model/CarpoolTrip.java | 88 ++++ .../carpooling/model/CarpoolTripBuilder.java | 101 +++++ .../updater/SiriETCarpoolingUpdater.java | 54 +-- .../SiriETCarpoolingUpdaterParameters.java | 3 +- .../opentripplanner/ext/flex/FlexRouter.java | 4 +- .../apis/gtfs/datafetchers/LegImpl.java | 5 + .../apis/transmodel/model/EnumTypes.java | 1 + .../apis/transmodel/model/plan/LegType.java | 5 + .../nearbystops/StreetNearbyStopFinder.java | 3 +- .../routing/algorithm/RoutingWorker.java | 1 - .../SiriETCarpoolingUpdaterConfig.java | 6 +- .../configure/ConstructApplication.java | 2 + .../configure/SiriETCarpoolingModule.java | 54 --- .../configure/UpdaterConfigurator.java | 11 +- .../transit/speed_test/SpeedTest.java | 4 +- 24 files changed, 1267 insertions(+), 160 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java rename application/src/ext/java/org/opentripplanner/ext/carpooling/model/{CarpoolTransitLeg.java => CarpoolLeg.java} (53%) rename application/src/ext/java/org/opentripplanner/ext/carpooling/model/{CarpoolTransitLegBuilder.java => CarpoolLegBuilder.java} (52%) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java delete mode 100644 application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java index b6b8f4b1377..10ce79b6337 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java @@ -1,3 +1,30 @@ package org.opentripplanner.ext.carpooling; -public interface CarpoolingRepository {} +import com.google.common.collect.ArrayListMultimap; +import java.util.Collection; +import java.util.Map; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.street.model.vertex.StreetVertex; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.site.AreaStop; + +/** + * The CarpoolingRepository interface allows for the management and retrieval of carpooling trips. + */ +public interface CarpoolingRepository { + /** + * Get all available carpool trips for routing. + */ + Collection getCarpoolTrips(); + + /** + * Add a carpool trip to the repository. + */ + void addCarpoolTrip(CarpoolTrip trip); + + CarpoolTrip getCarpoolTripByBoardingArea(AreaStop stop); + CarpoolTrip getCarpoolTripByAlightingArea(AreaStop stop); + + ArrayListMultimap getBoardingAreasForVertex(); + ArrayListMultimap getAlightingAreasForVertex(); +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index 1243292bdac..0c8a48370ea 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -6,5 +6,6 @@ import org.opentripplanner.routing.error.RoutingValidationException; public interface CarpoolingService { - List route(RouteRequest request) throws RoutingValidationException; + List route(RouteRequest request) + throws RoutingValidationException; } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index 178f8431c8a..e3c5dac9ee8 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -5,30 +5,34 @@ import jakarta.inject.Singleton; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository; +import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingService; import org.opentripplanner.framework.application.OTPFeature; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.standalone.api.OtpServerRequestContext; +import org.opentripplanner.street.service.StreetLimitationParametersService; -/** - * TODO CARPOOLING - */ @Module public class CarpoolingModule { @Provides @Singleton - public CarpoolingRepository provideCarpoolingRepository() { + public CarpoolingRepository provideCarpoolingRepository(Graph graph) { if (OTPFeature.CarPooling.isOff()) { return null; } - // TODO CARPOOLING - return null; + return new DefaultCarpoolingRepository(graph); } @Provides - public static CarpoolingService provideCarpoolingService(CarpoolingRepository repository) { + public static CarpoolingService provideCarpoolingService( + StreetLimitationParametersService streetLimitationParametersService, + CarpoolingRepository repository, + Graph graph + ) { if (OTPFeature.CarPooling.isOff()) { return null; } - // TODO CARPOOLING - return null; + return new DefaultCarpoolingService(streetLimitationParametersService, repository, graph); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java new file mode 100644 index 00000000000..0a0f2067178 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java @@ -0,0 +1,197 @@ +package org.opentripplanner.ext.carpooling.data; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimap; +import java.lang.reflect.Array; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder; +import org.opentripplanner.ext.flex.AreaStopsToVerticesMapper; +import org.opentripplanner.framework.geometry.GeometryUtils; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.routing.alertpatch.EntityKey; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.street.model.vertex.StreetVertex; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.network.Route; +import org.opentripplanner.transit.model.organization.Operator; +import org.opentripplanner.transit.model.site.AreaStop; +import org.opentripplanner.transit.model.timetable.Trip; + +/** + * Utility class to create realistic carpooling trip data for the Kristiansand area. + * + * This creates carpooling trips connecting popular areas around Kristiansand: + * - Kvadraturen (downtown) + * - Lund (university area) + * - Gimle (residential area) + * - Sørlandsparken (shopping center) + * - Varoddbrua (bridge area) + * - Torridal (industrial area) + */ +public class KristiansandCarpoolingData { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + private static final ZoneId KRISTIANSAND_TIMEZONE = ZoneId.of("Europe/Oslo"); + private static final AtomicInteger COUNTER = new AtomicInteger(0); + + /** + * Populates the repository with realistic Kristiansand carpooling trips + */ + public static void populateRepository(CarpoolingRepository repository, Graph graph) { + // Create area stops for popular Kristiansand locations + AreaStop kvadraturenPickup = createAreaStop("kvadraturen-pickup", 58.1458, 7.9959, 300); // Downtown center + AreaStop lundDropoff = createAreaStop("lund-dropoff", 58.1665, 8.0041, 400); // University of Agder area + + AreaStop gimlePickup = createAreaStop("gimle-pickup", 58.1547, 7.9899, 250); // Gimle residential + AreaStop sorlandparkenDropoff = createAreaStop("sorlandparken-dropoff", 58.0969, 7.9786, 500); // Shopping center + + AreaStop varoddbruaPickup = createAreaStop("varoddbrua-pickup", 58.1389, 8.0180, 200); // Bridge area + AreaStop torridalDropoff = createAreaStop("torridal-dropoff", 58.1244, 8.0500, 350); // Industrial area + + AreaStop lundPickup = createAreaStop("lund-pickup", 58.1665, 8.0041, 400); // University pickup + AreaStop kvadraturenDropoff = createAreaStop("kvadraturen-dropoff", 58.1458, 7.9959, 300); // Downtown dropoff + + // Create carpooling trips for typical commuting patterns + + // Morning commute: Downtown to University (07:30-08:00) + repository.addCarpoolTrip( + createCarpoolTrip( + "morning-downtown-to-uni", + kvadraturenPickup, + lundDropoff, + LocalTime.of(7, 30), + LocalTime.of(8, 0), + "KristiansandRides", + 3 + ) + ); + + // Morning commute: Gimle to Sørlandsparken (08:00-08:25) + repository.addCarpoolTrip( + createCarpoolTrip( + "morning-gimle-to-shopping", + gimlePickup, + sorlandparkenDropoff, + LocalTime.of(8, 0), + LocalTime.of(8, 25), + "ShareRideKRS", + 2 + ) + ); + + // Morning commute: Varoddbrua to Torridal (07:45-08:10) + repository.addCarpoolTrip( + createCarpoolTrip( + "morning-bridge-to-industrial", + varoddbruaPickup, + torridalDropoff, + LocalTime.of(7, 45), + LocalTime.of(8, 10), + "CommuteBuddy", + 4 + ) + ); + + // Evening commute: University back to Downtown (16:30-17:00) + repository.addCarpoolTrip( + createCarpoolTrip( + "evening-uni-to-downtown", + lundPickup, + kvadraturenDropoff, + LocalTime.of(16, 30), + LocalTime.of(17, 0), + "KristiansandRides", + 2 + ) + ); + + System.out.println( + "✅ Populated carpooling repository with " + + repository.getCarpoolTrips().size() + + " Kristiansand area trips" + ); + + // Print trip summary for verification + repository + .getCarpoolTrips() + .forEach(trip -> { + System.out.printf( + "🚗 %s: %s → %s (%s-%s) [%s seats, %s]%n", + trip.getId().getId(), + formatAreaName(trip.getBoardingArea().getId().getId()), + formatAreaName(trip.getAlightingArea().getId().getId()), + trip.getStartTime().toLocalTime(), + trip.getEndTime().toLocalTime(), + trip.getAvailableSeats(), + trip.getProvider() + ); + }); + } + + private static CarpoolTrip createCarpoolTrip( + String tripId, + AreaStop boardingArea, + AreaStop alightingArea, + LocalTime startTime, + LocalTime endTime, + String provider, + int availableSeats + ) { + ZonedDateTime now = ZonedDateTime.now(KRISTIANSAND_TIMEZONE); + ZonedDateTime startDateTime = now.with(startTime); + ZonedDateTime endDateTime = now.with(endTime); + + return new CarpoolTripBuilder(new FeedScopedId("CARPOOL", tripId)) + .withBoardingArea(boardingArea) + .withAlightingArea(alightingArea) + .withStartTime(startDateTime) + .withEndTime(endDateTime) + .withTrip(null) + .withProvider(provider) + .withAvailableSeats(availableSeats) + .build(); + } + + private static AreaStop createAreaStop(String id, double lat, double lon, double radiusMeters) { + WgsCoordinate center = new WgsCoordinate(lat, lon); + Polygon geometry = createCircularPolygon(center, radiusMeters); + + return AreaStop.of(new FeedScopedId("CARPOOL", id), COUNTER::getAndIncrement) + .withGeometry(geometry) + .build(); + } + + private static Polygon createCircularPolygon(WgsCoordinate center, double radiusMeters) { + // Create approximate circle using degree offsets (simplified for Kristiansand latitude) + double latOffset = radiusMeters / 111000.0; // ~111km per degree latitude + double lonOffset = radiusMeters / (111000.0 * Math.cos(Math.toRadians(center.latitude()))); // Adjust for latitude + + Coordinate[] coordinates = new Coordinate[13]; // 12 points + closing point + for (int i = 0; i < 12; i++) { + double angle = (2 * Math.PI * i) / 12; + double lat = center.latitude() + (latOffset * Math.cos(angle)); + double lon = center.longitude() + (lonOffset * Math.sin(angle)); + coordinates[i] = new Coordinate(lon, lat); + } + coordinates[12] = coordinates[0]; // Close the polygon + + return GEOMETRY_FACTORY.createPolygon(coordinates); + } + + private static String formatAreaName(String id) { + return id.replace("-pickup", "").replace("-dropoff", "").replace("-", " ").toUpperCase(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java index 16766237638..e695e6a7030 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java @@ -1,3 +1,93 @@ package org.opentripplanner.ext.carpooling.internal; -public class DefaultCarpoolingRepository {} +import com.google.common.collect.ArrayListMultimap; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.locationtech.jts.geom.Point; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.GeometryUtils; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.street.model.vertex.StreetVertex; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.site.AreaStop; + +public class DefaultCarpoolingRepository implements CarpoolingRepository { + + private final Graph graph; + + private final Map trips = new ConcurrentHashMap<>(); + + private final Map boardingAreas = new ConcurrentHashMap<>(); + private final Map alightingAreas = new ConcurrentHashMap<>(); + + private final ArrayListMultimap boardingAreasByVertex = + ArrayListMultimap.create(); + private final ArrayListMultimap alightingAreasByVertex = + ArrayListMultimap.create(); + + public DefaultCarpoolingRepository(Graph graph) { + this.graph = graph; + } + + @Override + public Collection getCarpoolTrips() { + return trips.values(); + } + + @Override + public void addCarpoolTrip(CarpoolTrip trip) { + trips.put(trip.getId(), trip); + + var boardingArea = trip.getBoardingArea(); + var alightingArea = trip.getAlightingArea(); + + boardingAreas.put(boardingArea, trip); + alightingAreas.put(alightingArea, trip); + + streetVerticesWithinAreaStop(boardingArea).forEach(v -> { + boardingAreasByVertex.put(v, boardingArea); + }); + + streetVerticesWithinAreaStop(alightingArea).forEach(v -> { + alightingAreasByVertex.put(v, alightingArea); + }); + } + + private List streetVerticesWithinAreaStop(AreaStop stop) { + return graph + .findVertices(stop.getGeometry().getEnvelopeInternal()) + .stream() + .filter(StreetVertex.class::isInstance) + .map(StreetVertex.class::cast) + .filter(StreetVertex::isEligibleForCarPickupDropoff) + .filter(vertx -> { + // The street index overselects, so need to check for exact geometry inclusion + Point p = GeometryUtils.getGeometryFactory().createPoint(vertx.getCoordinate()); + return stop.getGeometry().intersects(p); + }) + .toList(); + } + + @Override + public CarpoolTrip getCarpoolTripByBoardingArea(AreaStop boardingArea) { + return boardingAreas.get(boardingArea); + } + + @Override + public CarpoolTrip getCarpoolTripByAlightingArea(AreaStop alightingArea) { + return alightingAreas.get(alightingArea); + } + + @Override + public ArrayListMultimap getBoardingAreasForVertex() { + return boardingAreasByVertex; + } + + @Override + public ArrayListMultimap getAlightingAreasForVertex() { + return alightingAreasByVertex; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java index 5d55f02750e..4004c5e41cb 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java @@ -1,13 +1,76 @@ package org.opentripplanner.ext.carpooling.internal; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import org.locationtech.jts.geom.LineString; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; +import org.opentripplanner.astar.strategy.PathComparator; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.carpooling.data.KristiansandCarpoolingData; +import org.opentripplanner.ext.carpooling.model.CarpoolLeg; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.model.CarpoolItineraryCandidate; +import org.opentripplanner.framework.geometry.GeometryUtils; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.framework.i18n.NonLocalizedString; +import org.opentripplanner.framework.model.Cost; import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.Leg; +import org.opentripplanner.model.plan.Place; +import org.opentripplanner.model.plan.leg.StreetLeg; +import org.opentripplanner.routing.algorithm.mapping.StreetModeToTransferTraverseModeMapper; import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.preference.StreetPreferences; +import org.opentripplanner.routing.api.request.request.StreetRequest; +import org.opentripplanner.routing.api.response.InputField; +import org.opentripplanner.routing.api.response.RoutingError; +import org.opentripplanner.routing.api.response.RoutingErrorCode; import org.opentripplanner.routing.error.RoutingValidationException; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.graphfinder.NearbyStop; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.StreetVertex; +import org.opentripplanner.street.model.vertex.TemporaryStreetLocation; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.StreetSearchBuilder; +import org.opentripplanner.street.search.TemporaryVerticesContainer; +import org.opentripplanner.street.search.state.State; +import org.opentripplanner.street.search.strategy.DominanceFunctions; +import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; +import org.opentripplanner.street.service.StreetLimitationParametersService; +import org.opentripplanner.transit.model.site.AreaStop; public class DefaultCarpoolingService implements CarpoolingService { + private static final Duration MAX_BOOKING_WINDOW = Duration.ofHours(2); + private static final int DEFAULT_MAX_CARPOOL_RESULTS = 3; + + private final StreetLimitationParametersService streetLimitationParametersService; + private final CarpoolingRepository repository; + private final Graph graph; + + public DefaultCarpoolingService(StreetLimitationParametersService streetLimitationParametersService, CarpoolingRepository repository, Graph graph) { + KristiansandCarpoolingData.populateRepository(repository, graph); + this.streetLimitationParametersService = streetLimitationParametersService; + this.repository = repository; + this.graph = graph; + } + /** * TERMINOLOGY * - Boarding and alighting area stops @@ -39,8 +102,348 @@ public class DefaultCarpoolingService implements CarpoolingService { * Create Itineraries for the top 3 results and return * */ - @Override - public List route(RouteRequest request) throws RoutingValidationException { - return List.of(); + public List route(RouteRequest request) + throws RoutingValidationException { + if ( + Objects.requireNonNull(request.from()).lat == null || + Objects.requireNonNull(request.from()).lng == null + ) { + throw new RoutingValidationException( + List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.FROM_PLACE)) + ); + } + if ( + Objects.requireNonNull(request.to()).lat == null || + Objects.requireNonNull(request.to()).lng == null + ) { + throw new RoutingValidationException( + List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.TO_PLACE)) + ); + } + + List itineraryCandidates = getCarpoolItineraryCandidates(request); + if (itineraryCandidates.isEmpty()) { + // No relevant carpool trips found within the next 2 hours, return empty list + return Collections.emptyList(); + } + + // Perform A* routing for the top candidates and create itineraries + List itineraries = new ArrayList<>(); + int maxResults = Math.min(itineraryCandidates.size(), DEFAULT_MAX_CARPOOL_RESULTS); + + for (int i = 0; i < maxResults; i++) { + CarpoolItineraryCandidate candidate = itineraryCandidates.get(i); + + GraphPath routing = carpoolRouting( + request, + new StreetRequest(StreetMode.CAR), + candidate.boardingStop().state.getVertex(), + candidate.alightingStop().state.getVertex(), + streetLimitationParametersService.getMaxCarSpeed()); + + Itinerary itinerary = createItineraryFromRouting(request, candidate, routing); + if (itinerary != null) { + itineraries.add(itinerary); + } + } + + return itineraries; + } + + private List getCarpoolItineraryCandidates(RouteRequest request) { + TemporaryVerticesContainer temporaryVertices; + + try { + temporaryVertices = new TemporaryVerticesContainer( + graph, + request.from(), + request.to(), + request.journey().access().mode(), + request.journey().egress().mode()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // Prepare access/egress transfers + Collection accessStops = getClosestAreaStopsToVertex( + request, + request.journey().access(), + temporaryVertices.getFromVertices(), + null, + repository.getBoardingAreasForVertex()); + + Collection egressStops = getClosestAreaStopsToVertex( + request, + request.journey().egress(), + null, + temporaryVertices.getToVertices(), + repository.getAlightingAreasForVertex()); + + Map tripToBoardingStop = accessStops.stream() + .collect(Collectors.toMap( + stop -> repository.getCarpoolTripByBoardingArea((AreaStop) stop.stop), + stop -> stop + )); + + Map tripToAlightingStop = egressStops.stream() + .collect(Collectors.toMap( + stop -> repository.getCarpoolTripByAlightingArea((AreaStop) stop.stop), + stop -> stop + )); + + // Find trips that have both boarding and alighting stops + List itineraryCandidates = tripToBoardingStop.keySet().stream() + .filter(tripToAlightingStop::containsKey) + .map(trip -> new CarpoolItineraryCandidate( + trip, + tripToBoardingStop.get(trip), + tripToAlightingStop.get(trip) + )) + .filter(candidate -> + candidate.trip().getStartTime().toInstant().isAfter(candidate.boardingStop().state.getTime()) + && candidate.trip().getStartTime().toInstant().isBefore(candidate.boardingStop().state.getTime().plus(MAX_BOOKING_WINDOW))) + .toList(); + return itineraryCandidates; + } + + private List getClosestAreaStopsToVertex( + RouteRequest request, + StreetRequest streetRequest, + Set originVertices, + Set destinationVertices, + Multimap destinationAreas + ) { + var maxAccessEgressDuration = request.preferences().street().accessEgress().maxDuration().valueOf(streetRequest.mode()); + var arriveBy = originVertices == null && destinationVertices != null; + + var streetSearch = StreetSearchBuilder.of() + .setSkipEdgeStrategy( + new DurationSkipEdgeStrategy<>(maxAccessEgressDuration) + ) + .setDominanceFunction(new DominanceFunctions.MinimumWeight()) + .setRequest(request) + .setArriveBy(arriveBy) + .setStreetRequest(streetRequest) + .setFrom(originVertices) + .setTo(destinationVertices); + + var spt = streetSearch.getShortestPathTree(); + + if (spt == null) { + return Collections.emptyList(); + } + + // Get the reachable AreaStops from the vertices in the SPT + Multimap locationsMap = ArrayListMultimap.create(); + for (State state : spt.getAllStates()) { + Vertex targetVertex = state.getVertex(); + if ( + targetVertex instanceof StreetVertex streetVertex && destinationAreas.containsKey(streetVertex) + ) { + for (AreaStop areaStop : destinationAreas.get(streetVertex)) { + locationsMap.put(areaStop, state); + } + } + } + + // Map the minimum reachable state for each AreaStop and the AreaStop to NearbyStop + List stopsFound = new ArrayList<>(); + for (var locationStates : locationsMap.asMap().entrySet()) { + AreaStop areaStop = locationStates.getKey(); + State min = getMinState(locationStates); + + stopsFound.add(NearbyStop.nearbyStopForState(min, areaStop)); + } + return stopsFound; + } + + private static State getMinState(Map.Entry> locationStates) { + Collection states = locationStates.getValue(); + // Select the vertex from all vertices that are reachable per AreaStop by taking + // the minimum walking distance + State min = Collections.min(states, Comparator.comparing(State::getWeight)); + + // If the best state for this AreaStop is a SplitterVertex, we want to get the + // TemporaryStreetLocation instead. This allows us to reach SplitterVertices in both + // directions when routing later. + if (min.getBackState().getVertex() instanceof TemporaryStreetLocation) { + min = min.getBackState(); + } + return min; + } + + /** + * Performs A* street routing between two coordinates using the specified street mode. + * Returns the routing result with distance, time, and geometry. + */ + @Nullable + private GraphPath carpoolRouting( + RouteRequest request, + StreetRequest streetRequest, + Vertex from, + Vertex to, + float maxCarSpeed + ) { + StreetPreferences preferences = request.preferences().street(); + + var streetSearch = StreetSearchBuilder.of() + .setHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) + .setSkipEdgeStrategy( + new DurationSkipEdgeStrategy( + preferences.maxDirectDuration().valueOf(streetRequest.mode()) + ) + ) + .setDominanceFunction(new DominanceFunctions.MinimumWeight()) + .setRequest(request) + .setStreetRequest(streetRequest) + .setFrom(from) + .setTo(to); + + List> paths = streetSearch.getPathsToTarget(); + paths.sort(new PathComparator(request.arriveBy())); + + return paths.getFirst(); + } + + /** + * Creates a complete itinerary from A* routing results with proper walking and carpool legs + */ + private Itinerary createItineraryFromRouting(RouteRequest request, CarpoolItineraryCandidate candidate, GraphPath carpoolPath) { + List legs = new ArrayList<>(); + + // 1. Access walking leg (origin to pickup) + Leg accessLeg = createWalkingLegFromPath( + request.journey().access(), + candidate.boardingStop(), + null, + candidate.trip().getStartTime(), + "Walk to pickup" + ); + if (accessLeg != null) { + legs.add(accessLeg); + } + + var drivingEndTime = candidate.trip() + .getStartTime() + .plus( + Duration.between( + carpoolPath.states.getFirst().getTime(), + carpoolPath.states.getLast().getTime() + ) + ); + + // 2. Carpool transit leg (pickup to dropoff) + CarpoolLeg carpoolLeg = CarpoolLeg.of() + .withStartTime(candidate.trip().getStartTime()) + .withEndTime(drivingEndTime) + .withFrom( + createPlaceFromVertex( + carpoolPath.states.getFirst().getVertex(), + "Pickup at " + candidate.trip().getBoardingArea().getName() + ) + ) + .withTo( + createPlaceFromVertex( + carpoolPath.states.getLast().getVertex(), + "Dropoff at " + candidate.trip().getAlightingArea().getName() + ) + ) + .withGeometry( + GeometryUtils.concatenateLineStrings(carpoolPath.edges, Edge::getGeometry) + ) + .withDistanceMeters( + carpoolPath.edges.stream().mapToDouble(Edge::getDistanceMeters).sum() + ) + .withGeneralizedCost((int) carpoolPath.getWeight()) + .build(); + legs.add(carpoolLeg); + + // 3. Egress walking leg (dropoff to destination) + Leg egressLeg = createWalkingLegFromPath( + request.journey().egress(), + candidate.alightingStop(), + drivingEndTime, + null, + "Walk from dropoff" + ); + if (egressLeg != null) { + legs.add(egressLeg); + } + + return Itinerary.ofDirect(legs) + .withGeneralizedCost(Cost.costOfSeconds(accessLeg.generalizedCost() + + carpoolLeg.generalizedCost() + egressLeg.generalizedCost())) + .build(); + } + + /** + * Creates a walking leg from a GraphPath with proper geometry and timing. + * This reuses the same pattern as OTP's GraphPathToItineraryMapper but simplified + * for carpooling service use. + */ + @Nullable + private Leg createWalkingLegFromPath( + StreetRequest streetRequest, + NearbyStop nearbyStop, + ZonedDateTime legStartTime, + ZonedDateTime legEndTime, + String name + ) { + if (nearbyStop == null || nearbyStop.edges.isEmpty()) { + return null; + } + + var graphPath = new GraphPath<>(nearbyStop.state); + + var firstState = graphPath.states.getFirst(); + var lastState = graphPath.states.getLast(); + + List edges = nearbyStop.edges; + + if (edges.isEmpty()) { + return null; + } + + // Create geometry from edges + LineString geometry = GeometryUtils.concatenateLineStrings(edges, Edge::getGeometry); + + var legDuration = Duration.between(firstState.getTime(), lastState.getTime()); + if (legStartTime != null && legEndTime == null) { + legEndTime = legStartTime.plus(legDuration); + } else if (legEndTime != null && legStartTime == null) { + legStartTime = legEndTime.minus(legDuration); + } + + // Build the walking leg + return StreetLeg.of() + .withMode(StreetModeToTransferTraverseModeMapper.map(streetRequest.mode() == StreetMode.NOT_SET ? StreetMode.WALK : streetRequest.mode())) + .withStartTime(legStartTime) + .withEndTime(legEndTime) + .withFrom(createPlaceFromVertex(firstState.getVertex(), name + " start")) + .withTo(createPlaceFromVertex(lastState.getVertex(), name + " end")) + .withDistanceMeters(nearbyStop.distance) + .withGeneralizedCost((int) (lastState.getWeight() - firstState.getWeight())) + .withGeometry(geometry) + .build(); + } + + /** + * Creates a Place from a State, similar to GraphPathToItineraryMapper.makePlace + * but simplified for carpooling service use. + */ + private Place createPlaceFromVertex(Vertex vertex, String defaultName) { + I18NString name = vertex.getName(); + + // Use intersection name for street vertices to get better names + if (vertex instanceof StreetVertex && !(vertex instanceof TemporaryStreetLocation)) { + name = ((StreetVertex) vertex).getIntersectionName(); + } + + // If no name available, use default + if (name == null || name.toString().trim().isEmpty()) { + name = new NonLocalizedString(defaultName); + } + + return Place.normal(vertex, name); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java new file mode 100644 index 00000000000..3d10100d649 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java @@ -0,0 +1,9 @@ +package org.opentripplanner.ext.carpooling.model; + +import org.opentripplanner.routing.graphfinder.NearbyStop; + +public record CarpoolItineraryCandidate( + CarpoolTrip trip, + NearbyStop boardingStop, + NearbyStop alightingStop +) {} \ No newline at end of file diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java similarity index 53% rename from application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java rename to application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java index f0695ee2643..6d86e9780a6 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLeg.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java @@ -14,17 +14,23 @@ import org.opentripplanner.model.plan.Emission; import org.opentripplanner.model.plan.Leg; import org.opentripplanner.model.plan.Place; -import org.opentripplanner.model.plan.TransitLeg; +import org.opentripplanner.model.plan.leg.ElevationProfile; import org.opentripplanner.model.plan.leg.LegCallTime; +import org.opentripplanner.model.plan.leg.ScheduledTransitLeg; import org.opentripplanner.model.plan.leg.StopArrival; +import org.opentripplanner.model.plan.legreference.LegReference; +import org.opentripplanner.model.plan.walkstep.WalkStep; +import org.opentripplanner.model.transfer.ConstrainedTransfer; import org.opentripplanner.routing.alertpatch.TransitAlert; +import org.opentripplanner.street.model.note.StreetNote; import org.opentripplanner.transit.model.basic.Accessibility; import org.opentripplanner.transit.model.basic.TransitMode; -import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.network.Route; import org.opentripplanner.transit.model.organization.Agency; import org.opentripplanner.transit.model.organization.Operator; -import org.opentripplanner.transit.model.timetable.Trip; +import org.opentripplanner.transit.model.site.FareZone; +import org.opentripplanner.transit.model.timetable.RealTimeState; +import org.opentripplanner.transit.model.timetable.TripOnServiceDate; import org.opentripplanner.transit.model.timetable.booking.BookingInfo; import org.opentripplanner.utils.tostring.ToStringBuilder; @@ -32,7 +38,7 @@ * One leg of a trip -- that is, a temporally continuous piece of the journey that takes place on a * particular vehicle, which is running on flexible trip, i.e. not using fixed schedule and stops. */ -public class CarpoolTransitLeg implements TransitLeg { +public class CarpoolLeg implements Leg { private final ZonedDateTime startTime; @@ -46,46 +52,113 @@ public class CarpoolTransitLeg implements TransitLeg { private final List fareProducts; - CarpoolTransitLeg(CarpoolTransitLegBuilder builder) { + private final Place from; + + private final Place to; + + private final LineString geometry; + + private final double distanceMeters; + + CarpoolLeg(CarpoolLegBuilder builder) { this.startTime = Objects.requireNonNull(builder.startTime()); this.endTime = Objects.requireNonNull(builder.endTime()); this.generalizedCost = builder.generalizedCost(); this.transitAlerts = Set.copyOf(builder.alerts()); this.fareProducts = List.copyOf(builder.fareProducts()); this.emissionPerPerson = builder.emissionPerPerson(); + this.from = builder.from(); + this.to = builder.to(); + this.geometry = builder.geometry(); + this.distanceMeters = builder.distanceMeters(); } /** - * Return an empty builder for {@link CarpoolTransitLeg}. + * Return an empty builder for {@link CarpoolLeg}. */ - public static CarpoolTransitLegBuilder of() { - return new CarpoolTransitLegBuilder(); + public static CarpoolLegBuilder of() { + return new CarpoolLegBuilder(); + } + + public CarpoolLegBuilder copyOf() { + return new CarpoolLegBuilder(this); + } + + @Override + public boolean isTransitLeg() { + return false; + } + + @Override + public boolean isScheduledTransitLeg() { + return Leg.super.isScheduledTransitLeg(); + } + + @Override + public ScheduledTransitLeg asScheduledTransitLeg() { + return Leg.super.asScheduledTransitLeg(); + } + + @Override + public Boolean isInterlinedWithPreviousLeg() { + return Leg.super.isInterlinedWithPreviousLeg(); } - public CarpoolTransitLegBuilder copyOf() { - return new CarpoolTransitLegBuilder(this); + @Override + public boolean isWalkingLeg() { + return Leg.super.isWalkingLeg(); + } + + @Override + public boolean isStreetLeg() { + return Leg.super.isStreetLeg(); + } + + @Override + public Duration duration() { + return Leg.super.duration(); + } + + @Override + public boolean isPartiallySameTransitLeg(Leg other) { + return Leg.super.isPartiallySameTransitLeg(other); + } + + @Override + public boolean hasSameMode(Leg other) { + return false; + } + + @Override + public boolean isPartiallySameLeg(Leg other) { + return Leg.super.isPartiallySameLeg(other); + } + + @Override + public boolean overlapInTime(Leg other) { + return Leg.super.overlapInTime(other); } @Override public Agency agency() { - return trip().getRoute().getAgency(); + return null; } @Override @Nullable public Operator operator() { - return trip().getOperator(); + return null; } @Override public Route route() { - return trip().getRoute(); + return null; } + @Nullable @Override - public Trip trip() { - // TODO CARPOOLING - return null; + public TripOnServiceDate tripOnServiceDate() { + return Leg.super.tripOnServiceDate(); } @Override @@ -104,11 +177,6 @@ public LegCallTime end() { return LegCallTime.ofStatic(endTime); } - @Override - public TransitMode mode() { - return trip().getMode(); - } - @Override public ZonedDateTime startTime() { return startTime; @@ -119,25 +187,62 @@ public ZonedDateTime endTime() { return endTime; } + @Override + public int departureDelay() { + return Leg.super.departureDelay(); + } + + @Override + public int arrivalDelay() { + return Leg.super.arrivalDelay(); + } + + @Override + public boolean isRealTimeUpdated() { + return Leg.super.isRealTimeUpdated(); + } + + @Nullable + @Override + public RealTimeState realTimeState() { + return Leg.super.realTimeState(); + } + @Override public boolean isFlexibleTrip() { return true; } + @Nullable + @Override + public Boolean isNonExactFrequency() { + return Leg.super.isNonExactFrequency(); + } + + @Nullable + @Override + public Integer headway() { + return Leg.super.headway(); + } + @Override public double distanceMeters() { - // TODO CARPOOLING - return 999_999.0; + return distanceMeters; + } + + @Override + public int agencyTimeZoneOffset() { + return Leg.super.agencyTimeZoneOffset(); } @Override public Integer routeType() { - return trip().getRoute().getGtfsType(); + return null; } @Override public I18NString headsign() { - return trip().getHeadsign(); + return null; } @Override @@ -146,16 +251,20 @@ public LocalDate serviceDate() { return null; } + @Nullable + @Override + public String routeBrandingUrl() { + return Leg.super.routeBrandingUrl(); + } + @Override public Place from() { - // TODO CARPOOLING - return null; + return from; } @Override public Place to() { - // TODO CARPOOLING - return null; + return to; } @Override @@ -165,23 +274,28 @@ public List listIntermediateStops() { @Override public LineString legGeometry() { - // TODO CARPOOLING - return null; + return geometry; } + @Nullable @Override - public Set listTransitAlerts() { - return transitAlerts; + public ElevationProfile elevationProfile() { + return Leg.super.elevationProfile(); } @Override - public TransitLeg decorateWithAlerts(Set alerts) { - return copyOf().withAlerts(alerts).build(); + public List listWalkSteps() { + return Leg.super.listWalkSteps(); } @Override - public TransitLeg decorateWithFareProducts(List fares) { - return copyOf().withFareProducts(fares).build(); + public Set listStreetNotes() { + return Leg.super.listStreetNotes(); + } + + @Override + public Set listTransitAlerts() { + return transitAlerts; } @Override @@ -208,6 +322,18 @@ public BookingInfo pickupBookingInfo() { return null; } + @Nullable + @Override + public ConstrainedTransfer transferFromPrevLeg() { + return Leg.super.transferFromPrevLeg(); + } + + @Nullable + @Override + public ConstrainedTransfer transferToNextLeg() { + return Leg.super.transferToNextLeg(); + } + @Override public Integer boardStopPosInPattern() { // TODO CARPOOLING @@ -220,11 +346,41 @@ public Integer alightStopPosInPattern() { return 1; } + @Nullable + @Override + public Integer boardingGtfsStopSequence() { + return Leg.super.boardingGtfsStopSequence(); + } + + @Nullable + @Override + public Integer alightGtfsStopSequence() { + return Leg.super.alightGtfsStopSequence(); + } + + @Nullable + @Override + public Boolean walkingBike() { + return Leg.super.walkingBike(); + } + + @Nullable + @Override + public Float accessibilityScore() { + return Leg.super.accessibilityScore(); + } + @Override public int generalizedCost() { return generalizedCost; } + @Nullable + @Override + public LegReference legReference() { + return Leg.super.legReference(); + } + @Override public Leg withTimeShift(Duration duration) { return copyOf() @@ -233,6 +389,11 @@ public Leg withTimeShift(Duration duration) { .build(); } + @Override + public Set fareZones() { + return Leg.super.fareZones(); + } + @Nullable @Override public Emission emissionPerPerson() { @@ -245,26 +406,39 @@ public Leg withEmissionPerPerson(Emission emissionPerPerson) { return copyOf().withEmissionPerPerson(emissionPerPerson).build(); } + @Nullable + @Override + public Boolean rentedVehicle() { + return Leg.super.rentedVehicle(); + } + + @Nullable + @Override + public String vehicleRentalNetwork() { + return Leg.super.vehicleRentalNetwork(); + } + @Override public List fareProducts() { return fareProducts; } + public TransitMode mode() { + return TransitMode.CARPOOL; + } + /** * Should be used for debug logging only */ @Override public String toString() { - return ToStringBuilder.of(CarpoolTransitLeg.class) + return ToStringBuilder.of(CarpoolLeg.class) .addObj("from", from()) .addObj("to", to()) .addTime("startTime", startTime) .addTime("endTime", endTime) .addNum("distance", distanceMeters(), "m") .addNum("cost", generalizedCost) - .addObjOp("agencyId", agency(), AbstractTransitEntity::getId) - .addObjOp("routeId", route(), AbstractTransitEntity::getId) - .addObjOp("tripId", trip(), AbstractTransitEntity::getId) .addObj("serviceDate", serviceDate()) .addObj("legGeometry", legGeometry()) .addCol("transitAlerts", transitAlerts) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java similarity index 52% rename from application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java rename to application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java index b59e4d78373..5536b6f7e8a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTransitLegBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java @@ -6,11 +6,13 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import org.locationtech.jts.geom.LineString; import org.opentripplanner.model.fare.FareProductUse; import org.opentripplanner.model.plan.Emission; +import org.opentripplanner.model.plan.Place; import org.opentripplanner.routing.alertpatch.TransitAlert; -public class CarpoolTransitLegBuilder { +public class CarpoolLegBuilder { private ZonedDateTime startTime; private ZonedDateTime endTime; @@ -18,19 +20,27 @@ public class CarpoolTransitLegBuilder { private Set transitAlerts = new HashSet<>(); private List fareProducts = new ArrayList<>(); private Emission emissionPerPerson; + private Place from; + private Place to; + private LineString geometry; + private double distanceMeters; - CarpoolTransitLegBuilder() {} + CarpoolLegBuilder() {} - CarpoolTransitLegBuilder(CarpoolTransitLeg original) { + CarpoolLegBuilder(CarpoolLeg original) { startTime = original.startTime(); endTime = original.endTime(); generalizedCost = original.generalizedCost(); transitAlerts = original.listTransitAlerts(); fareProducts = original.fareProducts(); emissionPerPerson = original.emissionPerPerson(); + from = original.from(); + to = original.to(); + geometry = original.legGeometry(); + distanceMeters = original.distanceMeters(); } - public CarpoolTransitLegBuilder withStartTime(ZonedDateTime startTime) { + public CarpoolLegBuilder withStartTime(ZonedDateTime startTime) { this.startTime = startTime; return this; } @@ -39,7 +49,7 @@ public ZonedDateTime startTime() { return startTime; } - public CarpoolTransitLegBuilder withEndTime(ZonedDateTime endTime) { + public CarpoolLegBuilder withEndTime(ZonedDateTime endTime) { this.endTime = endTime; return this; } @@ -48,7 +58,7 @@ public ZonedDateTime endTime() { return endTime; } - public CarpoolTransitLegBuilder withGeneralizedCost(int generalizedCost) { + public CarpoolLegBuilder withGeneralizedCost(int generalizedCost) { this.generalizedCost = generalizedCost; return this; } @@ -57,7 +67,7 @@ public int generalizedCost() { return generalizedCost; } - public CarpoolTransitLegBuilder withAlerts(Collection alerts) { + public CarpoolLegBuilder withAlerts(Collection alerts) { this.transitAlerts = Set.copyOf(alerts); return this; } @@ -66,7 +76,7 @@ public Set alerts() { return transitAlerts; } - public CarpoolTransitLegBuilder withFareProducts(List allUses) { + public CarpoolLegBuilder withFareProducts(List allUses) { this.fareProducts = List.copyOf(allUses); return this; } @@ -75,7 +85,7 @@ public List fareProducts() { return fareProducts; } - public CarpoolTransitLegBuilder withEmissionPerPerson(Emission emissionPerPerson) { + public CarpoolLegBuilder withEmissionPerPerson(Emission emissionPerPerson) { this.emissionPerPerson = emissionPerPerson; return this; } @@ -84,7 +94,43 @@ public Emission emissionPerPerson() { return emissionPerPerson; } - public CarpoolTransitLeg build() { - return new CarpoolTransitLeg(this); + public CarpoolLegBuilder withFrom(Place from) { + this.from = from; + return this; + } + + public Place from() { + return from; + } + + public CarpoolLegBuilder withTo(Place to) { + this.to = to; + return this; + } + + public Place to() { + return to; + } + + public CarpoolLegBuilder withGeometry(LineString geometry) { + this.geometry = geometry; + return this; + } + + public LineString geometry() { + return geometry; + } + + public CarpoolLegBuilder withDistanceMeters(double distanceMeters) { + this.distanceMeters = distanceMeters; + return this; + } + + public double distanceMeters() { + return distanceMeters; + } + + public CarpoolLeg build() { + return new CarpoolLeg(this); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java new file mode 100644 index 00000000000..17014019d6e --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -0,0 +1,88 @@ +package org.opentripplanner.ext.carpooling.model; + +import java.time.ZonedDateTime; +import javax.annotation.Nullable; +import org.opentripplanner.transit.model.framework.AbstractTransitEntity; +import org.opentripplanner.transit.model.framework.LogInfo; +import org.opentripplanner.transit.model.framework.TransitBuilder; +import org.opentripplanner.transit.model.site.AreaStop; +import org.opentripplanner.transit.model.timetable.Trip; + +/** + * A carpool trip is defined by two area stops and a start time, in addition to all the other fields + * that are necessary for a valid Trip. It is created from SIRI ET messages that contain the + * necessary identifiers and trip information. + */ +public class CarpoolTrip + extends AbstractTransitEntity + implements LogInfo { + + private final AreaStop boardingArea; + private final AreaStop alightingArea; + private final ZonedDateTime startTime; + private final ZonedDateTime endTime; + private final Trip trip; + private final String provider; + private final int availableSeats; + + public CarpoolTrip(CarpoolTripBuilder builder) { + super(builder.getId()); + this.boardingArea = builder.getBoardingArea(); + this.alightingArea = builder.getAlightingArea(); + this.startTime = builder.getStartTime(); + this.endTime = builder.getEndTime(); + this.trip = builder.getTrip(); + this.provider = builder.getProvider(); + this.availableSeats = builder.getAvailableSeats(); + } + + public AreaStop getBoardingArea() { + return boardingArea; + } + + public AreaStop getAlightingArea() { + return alightingArea; + } + + public ZonedDateTime getStartTime() { + return startTime; + } + + public ZonedDateTime getEndTime() { + return endTime; + } + + public Trip getTrip() { + return trip; + } + + public String getProvider() { + return provider; + } + + public int getAvailableSeats() { + return availableSeats; + } + + @Nullable + @Override + public String logName() { + return getId().toString(); + } + + @Override + public boolean sameAs(CarpoolTrip other) { + return ( + getId().equals(other.getId()) && + boardingArea.equals(other.boardingArea) && + alightingArea.equals(other.alightingArea) && + startTime.equals(other.startTime) && + endTime.equals(other.endTime) + ); + } + + @Override + public TransitBuilder copy() { + return new CarpoolTripBuilder(this); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java new file mode 100644 index 00000000000..3b28a468a13 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java @@ -0,0 +1,101 @@ +package org.opentripplanner.ext.carpooling.model; + +import java.time.ZonedDateTime; +import org.opentripplanner.transit.model.framework.AbstractEntityBuilder; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.site.AreaStop; +import org.opentripplanner.transit.model.timetable.Trip; + +public class CarpoolTripBuilder extends AbstractEntityBuilder { + + private AreaStop boardingArea; + private AreaStop alightingArea; + private ZonedDateTime startTime; + private ZonedDateTime endTime; + private Trip trip; + private String provider; + private int availableSeats = 1; + + public CarpoolTripBuilder(CarpoolTrip original) { + super(original); + this.boardingArea = original.getBoardingArea(); + this.alightingArea = original.getAlightingArea(); + this.startTime = original.getStartTime(); + this.endTime = original.getEndTime(); + this.trip = original.getTrip(); + this.provider = original.getProvider(); + this.availableSeats = original.getAvailableSeats(); + } + + public CarpoolTripBuilder(FeedScopedId id) { + super(id); + } + + public CarpoolTripBuilder withBoardingArea(AreaStop boardingArea) { + this.boardingArea = boardingArea; + return this; + } + + public CarpoolTripBuilder withAlightingArea(AreaStop alightingArea) { + this.alightingArea = alightingArea; + return this; + } + + public CarpoolTripBuilder withStartTime(ZonedDateTime startTime) { + this.startTime = startTime; + return this; + } + + public CarpoolTripBuilder withEndTime(ZonedDateTime endTime) { + this.endTime = endTime; + return this; + } + + public CarpoolTripBuilder withTrip(Trip trip) { + this.trip = trip; + return this; + } + + public CarpoolTripBuilder withProvider(String provider) { + this.provider = provider; + return this; + } + + public CarpoolTripBuilder withAvailableSeats(int availableSeats) { + this.availableSeats = availableSeats; + return this; + } + + public AreaStop getBoardingArea() { + return boardingArea; + } + + public AreaStop getAlightingArea() { + return alightingArea; + } + + public ZonedDateTime getStartTime() { + return startTime; + } + + public ZonedDateTime getEndTime() { + return endTime; + } + + public Trip getTrip() { + return trip; + } + + public String getProvider() { + return provider; + } + + public int getAvailableSeats() { + return availableSeats; + } + + @Override + protected CarpoolTrip buildFromValues() { + return new CarpoolTrip(this); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index 8e0dedaca2f..613a73cf031 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -2,14 +2,17 @@ import java.util.List; import java.util.function.Consumer; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.updater.spi.PollingGraphUpdater; import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; -import org.opentripplanner.updater.spi.ResultLogger; import org.opentripplanner.updater.spi.UpdateResult; +import org.opentripplanner.updater.support.siri.SiriFileLoader; +import org.opentripplanner.updater.support.siri.SiriHttpLoader; +import org.opentripplanner.updater.support.siri.SiriLoader; import org.opentripplanner.updater.trip.UrlUpdaterParameters; -import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter; -import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableHandler; +import org.opentripplanner.updater.trip.metrics.TripUpdateMetrics; import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; +import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; import org.opentripplanner.utils.tostring.ToStringBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,37 +30,32 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { */ private final EstimatedTimetableSource updateSource; + private final CarpoolingRepository repository; + /** * Feed id that is used for the trip ids in the TripUpdates */ private final String feedId; - private final EstimatedTimetableHandler estimatedTimetableHandler; - private final Consumer metricsConsumer; public SiriETCarpoolingUpdater( - Parameters config, - SiriRealTimeTripUpdateAdapter adapter, - EstimatedTimetableSource source, - Consumer metricsConsumer + SiriETCarpoolingUpdaterParameters config, + CarpoolingRepository repository ) { super(config); this.feedId = config.feedId(); - this.updateSource = source; + SiriLoader siriHttpLoader = siriLoader(config); + updateSource = new SiriETHttpTripUpdateSource(config.sourceParameters(), siriHttpLoader); + + this.repository = repository; this.blockReadinessUntilInitialized = config.blockReadinessUntilInitialized(); LOG.info("Creating SIRI-ET updater running every {}: {}", pollingPeriod(), updateSource); - estimatedTimetableHandler = new EstimatedTimetableHandler( - adapter, - config.fuzzyTripMatching(), - feedId - ); - - this.metricsConsumer = metricsConsumer; + this.metricsConsumer = TripUpdateMetrics.streaming(config); } /** @@ -79,14 +77,7 @@ public void runPolling() { List etds = serviceDelivery.getEstimatedTimetableDeliveries(); if (etds != null) { - updateGraph(context -> { - var result = estimatedTimetableHandler.applyUpdate(etds, incrementality, context); - ResultLogger.logUpdateResult(feedId, "siri-et", result); - metricsConsumer.accept(result); - if (markPrimed) { - primed = true; - } - }); + LOG.info("Received {} estimated timetable deliveries", etds.size()); } } } while (moreData); @@ -107,4 +98,17 @@ public interface Parameters extends UrlUpdaterParameters, PollingGraphUpdaterPar boolean fuzzyTripMatching(); } + + private static SiriLoader siriLoader(SiriETCarpoolingUpdaterParameters config) { + // Load real-time updates from a file. + if (SiriFileLoader.matchesUrl(config.url())) { + return new SiriFileLoader(config.url()); + } + return new SiriHttpLoader( + config.url(), + config.timeout(), + config.httpRequestHeaders(), + config.previewInterval() + ); + } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java index 4ada97e5426..ab4530d3866 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java @@ -3,7 +3,6 @@ import java.time.Duration; import org.opentripplanner.updater.spi.HttpHeaders; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdater; public record SiriETCarpoolingUpdaterParameters( String configRef, @@ -18,7 +17,7 @@ public record SiriETCarpoolingUpdaterParameters( HttpHeaders httpRequestHeaders, boolean producerMetrics ) - implements SiriETUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters { + implements SiriETCarpoolingUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters { public SiriETHttpTripUpdateSource.Parameters sourceParameters() { return this; } diff --git a/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java b/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java index f84fa51f67b..669fb7f5bcc 100644 --- a/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java +++ b/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java @@ -68,13 +68,13 @@ public FlexRouter( int additionalPastSearchDays, int additionalFutureSearchDays, Collection streetAccesses, - Collection egressTransfers + Collection streetEgresses ) { this.graph = graph; this.transitService = transitService; this.flexParameters = flexParameters; this.streetAccesses = streetAccesses; - this.streetEgresses = egressTransfers; + this.streetEgresses = streetEgresses; this.flexIndex = transitService.getFlexIndex(); this.matcher = TripMatcherFactory.of( filterRequest, diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java index 72003a48a60..ebec980835e 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java @@ -14,6 +14,7 @@ import org.opentripplanner.apis.gtfs.mapping.RealtimeStateMapper; import org.opentripplanner.apis.gtfs.service.ApiTransitService; import org.opentripplanner.apis.gtfs.support.filter.StopArrivalByTypeFilter; +import org.opentripplanner.ext.carpooling.model.CarpoolLeg; import org.opentripplanner.ext.ridehailing.model.RideEstimate; import org.opentripplanner.ext.ridehailing.model.RideHailingLeg; import org.opentripplanner.framework.graphql.GraphQLUtils; @@ -174,6 +175,10 @@ public DataFetcher mode() { if (leg instanceof TransitLeg s) { return s.mode().name(); } + if (leg instanceof CarpoolLeg cl) { + // CarpoolLeg is a special case, it has no StreetLeg or TransitLeg, but we can still return the mode + return String.valueOf(cl.mode()); + } throw new IllegalStateException("Unhandled leg type: " + leg); }; } diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java index b4dfb38e290..12b6c97c084 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java @@ -185,6 +185,7 @@ public class EnumTypes { .value("foot", TraverseMode.WALK) .value("car", TraverseMode.CAR) .value("scooter", TraverseMode.SCOOTER) + .value("carpool", TransitMode.CARPOOL) .build(); public static final GraphQLEnumType LOCALE = GraphQLEnumType.newEnum() diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java index f3fd875b6e5..ddf866980f7 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java @@ -27,6 +27,7 @@ import org.opentripplanner.apis.transmodel.model.framework.TransmodelDirectives; import org.opentripplanner.apis.transmodel.model.framework.TransmodelScalars; import org.opentripplanner.apis.transmodel.support.GqlUtil; +import org.opentripplanner.ext.carpooling.model.CarpoolLeg; import org.opentripplanner.model.plan.Leg; import org.opentripplanner.model.plan.TransitLeg; import org.opentripplanner.model.plan.leg.StopArrival; @@ -514,6 +515,10 @@ private static Object onLeg( if (leg instanceof TransitLeg tl) { return transitLegAccessor.apply(tl); } + if (leg instanceof CarpoolLeg cl) { + // CarpoolLeg is a special case, it has no StreetLeg or TransitLeg, but we can still return the mode + return cl.mode(); + } throw new IllegalStateException("Unhandled leg type: " + leg); } } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java b/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java index 0cc7ab5ffda..49cf10d51fe 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java @@ -163,8 +163,7 @@ public Collection findNearbyStops( targetVertex instanceof StreetVertex streetVertex && !streetVertex.areaStops().isEmpty() ) { - for (FeedScopedId id : targetVertex.areaStops()) { - AreaStop areaStop = Objects.requireNonNull(stopResolver.getAreaStop(id)); + for (AreaStop areaStop : streetVertex.areaStops()) { // This is for a simplification, so that we only return one vertex from each // stop location. All vertices are added to the multimap, which is filtered // below, so that only the closest vertex is added to stopsFound diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java index 7dc32d20f58..9f1cab5e8f7 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java @@ -264,7 +264,6 @@ private RoutingResult routeCarpooling() { // TODO CARPOOLING Add carpooling timer // debugTimingAggregator.startedCarpoolingRouter(); try { - // TODO CARPOOLING return RoutingResult.ok(serverContext.carpoolingService().route(request)); } catch (RoutingValidationException e) { return RoutingResult.failed(e.getRoutingErrors()); diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java index e558b51f1a4..45281eabbfa 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java @@ -5,13 +5,13 @@ import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; import java.time.Duration; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; import org.opentripplanner.standalone.config.framework.json.NodeAdapter; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; public class SiriETCarpoolingUpdaterConfig { - public static SiriETUpdaterParameters create(String configRef, NodeAdapter c) { - return new SiriETUpdaterParameters( + public static SiriETCarpoolingUpdaterParameters create(String configRef, NodeAdapter c) { + return new SiriETCarpoolingUpdaterParameters( configRef, c.of("feedId").since(V2_0).summary("The ID of the feed to apply the updates to.").asString(), c diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java index 9baeae7b195..28a959515e0 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java @@ -192,6 +192,8 @@ private void setupTransitRoutingServer() { vehicleRentalRepository(), vehicleParkingRepository(), timetableRepository(), + //TODO Carpooling inject car pooling repository + null, snapshotManager(), routerConfig().updaterConfig() ); diff --git a/application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java b/application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java deleted file mode 100644 index a98b8ffc664..00000000000 --- a/application/src/main/java/org/opentripplanner/updater/configure/SiriETCarpoolingModule.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.opentripplanner.updater.configure; - -import java.util.function.Consumer; -import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater; -import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; -import org.opentripplanner.updater.spi.UpdateResult; -import org.opentripplanner.updater.support.siri.SiriFileLoader; -import org.opentripplanner.updater.support.siri.SiriLoader; -import org.opentripplanner.updater.trip.metrics.TripUpdateMetrics; -import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter; -import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; - -public class SiriETCarpoolingModule { - - public static SiriETCarpoolingUpdater createSiriETCarpoolingUpdater( - SiriETCarpoolingUpdaterParameters params, - SiriRealTimeTripUpdateAdapter adapter - ) { - return null; - //return new SiriETCarpoolingUpdater(params, adapter, createSource(params), createMetricsConsumer(params)); - } - - private static EstimatedTimetableSource createSource(SiriETCarpoolingUpdater.Parameters params) { - return null; - /** - return new new SiriETHttpTripUpdateSource( - params.sourceParameters(), - createLoader(params) - ); - */ - } - - private static SiriLoader createLoader(SiriETCarpoolingUpdater.Parameters params) { - // Load real-time updates from a file. - if (SiriFileLoader.matchesUrl(params.url())) { - return new SiriFileLoader(params.url()); - } - return null; - /** - return new SiriHttpLoader( - params.url(), - params.timeout(), - params.httpRequestHeaders(), - params.previewInterval() - ); - */ - } - - private static Consumer createMetricsConsumer( - SiriETCarpoolingUpdater.Parameters params - ) { - return TripUpdateMetrics.streaming(params); - } -} diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index 033cd266272..208346e67ab 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater; import org.opentripplanner.ext.siri.updater.azure.SiriAzureUpdater; import org.opentripplanner.ext.vehiclerentalservicedirectory.VehicleRentalServiceDirectoryFetcher; import org.opentripplanner.ext.vehiclerentalservicedirectory.api.VehicleRentalServiceDirectoryFetcherParameters; @@ -51,6 +53,7 @@ public class UpdaterConfigurator { private final UpdatersParameters updatersParameters; private final RealtimeVehicleRepository realtimeVehicleRepository; private final VehicleRentalRepository vehicleRentalRepository; + private final CarpoolingRepository carpoolingRepository; private final VehicleParkingRepository parkingRepository; private final TimetableSnapshotManager snapshotManager; @@ -61,6 +64,7 @@ private UpdaterConfigurator( VehicleRentalRepository vehicleRentalRepository, VehicleParkingRepository parkingRepository, TimetableRepository timetableRepository, + CarpoolingRepository carpoolingRepository, TimetableSnapshotManager snapshotManager, UpdatersParameters updatersParameters ) { @@ -72,6 +76,7 @@ private UpdaterConfigurator( this.updatersParameters = updatersParameters; this.parkingRepository = parkingRepository; this.snapshotManager = snapshotManager; + this.carpoolingRepository = carpoolingRepository; } public static void configure( @@ -81,6 +86,7 @@ public static void configure( VehicleRentalRepository vehicleRentalRepository, VehicleParkingRepository parkingRepository, TimetableRepository timetableRepository, + CarpoolingRepository carpoolingRepository, TimetableSnapshotManager snapshotManager, UpdatersParameters updatersParameters ) { @@ -91,6 +97,7 @@ public static void configure( vehicleRentalRepository, parkingRepository, timetableRepository, + carpoolingRepository, snapshotManager, updatersParameters ).configure(); @@ -186,9 +193,7 @@ private List createUpdatersFromConfig() { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); } for (var configItem : updatersParameters.getSiriETCarpoolingUpdaterParameters()) { - updaters.add( - SiriETCarpoolingModule.createSiriETCarpoolingUpdater(configItem, provideSiriAdapter()) - ); + updaters.add(new SiriETCarpoolingUpdater(configItem, carpoolingRepository)); } for (var configItem : updatersParameters.getSiriETLiteUpdaterParameters()) { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index 1477107f774..aa02cf67410 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -13,7 +13,8 @@ import java.util.Map; import java.util.function.Predicate; import org.opentripplanner.TestServerContext; -import org.opentripplanner.ext.fares.impl.gtfs.DefaultFareService; +import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository; +import org.opentripplanner.ext.fares.impl.DefaultFareService; import org.opentripplanner.framework.application.OtpAppException; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.raptor.configure.RaptorConfig; @@ -101,6 +102,7 @@ public SpeedTest( new DefaultVehicleRentalService(), new DefaultVehicleParkingRepository(), timetableRepository, + new DefaultCarpoolingRepository(graph), new TimetableSnapshotManager(null, TimetableSnapshotParameters.DEFAULT, LocalDate::now), config.updatersConfig ); From 264a9334eb54e8ff9e701e231327909c1dc739f1 Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 19 Aug 2025 13:09:31 +0200 Subject: [PATCH 03/40] Factors out the itinerary mapping code into its own class and cleans up the CarpoolingRepository a bit. --- .../ext/carpooling/CarpoolingRepository.java | 10 - .../ext/carpooling/CarpoolingService.java | 3 +- .../configure/CarpoolingModule.java | 11 +- .../data/KristiansandCarpoolingData.java | 22 -- .../internal/CarpoolItineraryMapper.java | 182 ++++++++++++ .../internal/DefaultCarpoolingRepository.java | 7 + .../internal/DefaultCarpoolingService.java | 258 +++++------------- .../model/CarpoolItineraryCandidate.java | 2 +- .../ext/carpooling/model/CarpoolLeg.java | 15 +- .../carpooling/model/CarpoolLegBuilder.java | 14 - .../carpooling/updater/CarpoolSiriMapper.java | 97 +++++++ .../updater/SiriETCarpoolingUpdater.java | 19 ++ .../configure/ConstructApplication.java | 8 +- .../ConstructApplicationFactory.java | 4 + .../apis/transmodel/schema.graphql | 1 + 15 files changed, 401 insertions(+), 252 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java index 10ce79b6337..0e4672e7191 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java @@ -2,26 +2,16 @@ import com.google.common.collect.ArrayListMultimap; import java.util.Collection; -import java.util.Map; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.street.model.vertex.StreetVertex; -import org.opentripplanner.transit.model.framework.FeedScopedId; import org.opentripplanner.transit.model.site.AreaStop; /** * The CarpoolingRepository interface allows for the management and retrieval of carpooling trips. */ public interface CarpoolingRepository { - /** - * Get all available carpool trips for routing. - */ Collection getCarpoolTrips(); - - /** - * Add a carpool trip to the repository. - */ void addCarpoolTrip(CarpoolTrip trip); - CarpoolTrip getCarpoolTripByBoardingArea(AreaStop stop); CarpoolTrip getCarpoolTripByAlightingArea(AreaStop stop); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index 0c8a48370ea..1243292bdac 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -6,6 +6,5 @@ import org.opentripplanner.routing.error.RoutingValidationException; public interface CarpoolingService { - List route(RouteRequest request) - throws RoutingValidationException; + List route(RouteRequest request) throws RoutingValidationException; } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index e3c5dac9ee8..acedaa2079b 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -9,6 +9,7 @@ import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingService; import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.standalone.api.OtpServerRequestContext; import org.opentripplanner.street.service.StreetLimitationParametersService; @@ -28,11 +29,17 @@ public CarpoolingRepository provideCarpoolingRepository(Graph graph) { public static CarpoolingService provideCarpoolingService( StreetLimitationParametersService streetLimitationParametersService, CarpoolingRepository repository, - Graph graph + Graph graph, + VertexLinker vertexLinker ) { if (OTPFeature.CarPooling.isOff()) { return null; } - return new DefaultCarpoolingService(streetLimitationParametersService, repository, graph); + return new DefaultCarpoolingService( + streetLimitationParametersService, + repository, + graph, + vertexLinker + ); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java index 0a0f2067178..c8c88a3441e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java @@ -117,28 +117,6 @@ public static void populateRepository(CarpoolingRepository repository, Graph gra 2 ) ); - - System.out.println( - "✅ Populated carpooling repository with " + - repository.getCarpoolTrips().size() + - " Kristiansand area trips" - ); - - // Print trip summary for verification - repository - .getCarpoolTrips() - .forEach(trip -> { - System.out.printf( - "🚗 %s: %s → %s (%s-%s) [%s seats, %s]%n", - trip.getId().getId(), - formatAreaName(trip.getBoardingArea().getId().getId()), - formatAreaName(trip.getAlightingArea().getId().getId()), - trip.getStartTime().toLocalTime(), - trip.getEndTime().toLocalTime(), - trip.getAvailableSeats(), - trip.getProvider() - ); - }); } private static CarpoolTrip createCarpoolTrip( diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java new file mode 100644 index 00000000000..b48ea062323 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -0,0 +1,182 @@ +package org.opentripplanner.ext.carpooling.internal; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import org.locationtech.jts.geom.LineString; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.model.CarpoolItineraryCandidate; +import org.opentripplanner.ext.carpooling.model.CarpoolLeg; +import org.opentripplanner.framework.geometry.GeometryUtils; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.framework.i18n.NonLocalizedString; +import org.opentripplanner.framework.model.Cost; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.model.plan.Leg; +import org.opentripplanner.model.plan.Place; +import org.opentripplanner.model.plan.leg.StreetLeg; +import org.opentripplanner.routing.algorithm.mapping.StreetModeToTransferTraverseModeMapper; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.request.StreetRequest; +import org.opentripplanner.routing.graphfinder.NearbyStop; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.StreetVertex; +import org.opentripplanner.street.model.vertex.TemporaryStreetLocation; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +public class CarpoolItineraryMapper { + + /** + * Creates a complete itinerary from A* routing results with proper access/egress legs and carpool legs + */ + public static Itinerary mapToItinerary( + RouteRequest request, + CarpoolItineraryCandidate candidate, + GraphPath carpoolPath + ) { + List legs = new ArrayList<>(); + + // 1. Access walking leg (origin to pickup) + Leg accessLeg = accessEgressLeg( + request.journey().access(), + candidate.boardingStop(), + null, + candidate.trip().getStartTime(), + "Walk to pickup" + ); + if (accessLeg != null) { + legs.add(accessLeg); + } + + var drivingEndTime = candidate + .trip() + .getStartTime() + .plus( + Duration.between( + carpoolPath.states.getFirst().getTime(), + carpoolPath.states.getLast().getTime() + ) + ); + + // 2. Carpool transit leg (pickup to dropoff) + CarpoolLeg carpoolLeg = CarpoolLeg.of() + .withStartTime(candidate.trip().getStartTime()) + .withEndTime(drivingEndTime) + .withFrom( + createPlaceFromVertex( + carpoolPath.states.getFirst().getVertex(), + "Pickup at " + candidate.trip().getBoardingArea().getName() + ) + ) + .withTo( + createPlaceFromVertex( + carpoolPath.states.getLast().getVertex(), + "Dropoff at " + candidate.trip().getAlightingArea().getName() + ) + ) + .withGeometry(GeometryUtils.concatenateLineStrings(carpoolPath.edges, Edge::getGeometry)) + .withDistanceMeters(carpoolPath.edges.stream().mapToDouble(Edge::getDistanceMeters).sum()) + .withGeneralizedCost((int) carpoolPath.getWeight()) + .build(); + legs.add(carpoolLeg); + + // 3. Egress walking leg (dropoff to destination) + Leg egressLeg = accessEgressLeg( + request.journey().egress(), + candidate.alightingStop(), + carpoolLeg.endTime(), + null, + "Walk from dropoff" + ); + if (egressLeg != null) { + legs.add(egressLeg); + } + + return Itinerary.ofDirect(legs) + .withGeneralizedCost( + Cost.costOfSeconds( + accessLeg.generalizedCost() + carpoolLeg.generalizedCost() + egressLeg.generalizedCost() + ) + ) + .build(); + } + + /** + * Creates a walking leg from a GraphPath with proper geometry and timing. + * This reuses the same pattern as OTP's GraphPathToItineraryMapper but simplified + * for carpooling service use. + */ + @Nullable + private static Leg accessEgressLeg( + StreetRequest streetRequest, + NearbyStop nearbyStop, + ZonedDateTime legStartTime, + ZonedDateTime legEndTime, + String name + ) { + if (nearbyStop == null || nearbyStop.edges.isEmpty()) { + return null; + } + + var graphPath = new GraphPath<>(nearbyStop.state); + + var firstState = graphPath.states.getFirst(); + var lastState = graphPath.states.getLast(); + + List edges = nearbyStop.edges; + + if (edges.isEmpty()) { + return null; + } + + // Create geometry from edges + LineString geometry = GeometryUtils.concatenateLineStrings(edges, Edge::getGeometry); + + var legDuration = Duration.between(firstState.getTime(), lastState.getTime()); + if (legStartTime != null && legEndTime == null) { + legEndTime = legStartTime.plus(legDuration); + } else if (legEndTime != null && legStartTime == null) { + legStartTime = legEndTime.minus(legDuration); + } + + // Build the walking leg + return StreetLeg.of() + .withMode( + StreetModeToTransferTraverseModeMapper.map( + streetRequest.mode() == StreetMode.NOT_SET ? StreetMode.WALK : streetRequest.mode() + ) + ) + .withStartTime(legStartTime) + .withEndTime(legEndTime) + .withFrom(createPlaceFromVertex(firstState.getVertex(), name + " start")) + .withTo(createPlaceFromVertex(lastState.getVertex(), name + " end")) + .withDistanceMeters(nearbyStop.distance) + .withGeneralizedCost((int) (lastState.getWeight() - firstState.getWeight())) + .withGeometry(geometry) + .build(); + } + + /** + * Creates a Place from a State, similar to GraphPathToItineraryMapper.makePlace + * but simplified for carpooling service use. + */ + private static Place createPlaceFromVertex(Vertex vertex, String defaultName) { + I18NString name = vertex.getName(); + + // Use intersection name for street vertices to get better names + if (vertex instanceof StreetVertex && !(vertex instanceof TemporaryStreetLocation)) { + name = ((StreetVertex) vertex).getIntersectionName(); + } + + // If no name available, use default + if (name == null || name.toString().trim().isEmpty()) { + name = new NonLocalizedString(defaultName); + } + + return Place.normal(vertex, name); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java index e695e6a7030..55a1d91b4bb 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java @@ -13,9 +13,13 @@ import org.opentripplanner.street.model.vertex.StreetVertex; import org.opentripplanner.transit.model.framework.FeedScopedId; import org.opentripplanner.transit.model.site.AreaStop; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class DefaultCarpoolingRepository implements CarpoolingRepository { + private static final Logger LOG = LoggerFactory.getLogger(DefaultCarpoolingRepository.class); + private final Graph graph; private final Map trips = new ConcurrentHashMap<>(); @@ -54,6 +58,9 @@ public void addCarpoolTrip(CarpoolTrip trip) { streetVerticesWithinAreaStop(alightingArea).forEach(v -> { alightingAreasByVertex.put(v, alightingArea); }); + LOG.info("Added carpooling trip for start time: {}", + trip.getStartTime() + ); } private List streetVerticesWithinAreaStop(AreaStop stop) { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java index 4004c5e41cb..6d4e4f8bad8 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java @@ -3,7 +3,6 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; import java.time.Duration; -import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -14,25 +13,15 @@ import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; -import org.locationtech.jts.geom.LineString; import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.carpooling.data.KristiansandCarpoolingData; -import org.opentripplanner.ext.carpooling.model.CarpoolLeg; -import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.model.CarpoolItineraryCandidate; -import org.opentripplanner.framework.geometry.GeometryUtils; -import org.opentripplanner.framework.i18n.I18NString; -import org.opentripplanner.framework.i18n.NonLocalizedString; -import org.opentripplanner.framework.model.Cost; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.model.plan.Itinerary; -import org.opentripplanner.model.plan.Leg; -import org.opentripplanner.model.plan.Place; -import org.opentripplanner.model.plan.leg.StreetLeg; -import org.opentripplanner.routing.algorithm.mapping.StreetModeToTransferTraverseModeMapper; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; import org.opentripplanner.routing.api.request.preference.StreetPreferences; @@ -43,6 +32,7 @@ import org.opentripplanner.routing.error.RoutingValidationException; import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.routing.graphfinder.NearbyStop; +import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.StreetVertex; import org.opentripplanner.street.model.vertex.TemporaryStreetLocation; @@ -63,12 +53,19 @@ public class DefaultCarpoolingService implements CarpoolingService { private final StreetLimitationParametersService streetLimitationParametersService; private final CarpoolingRepository repository; private final Graph graph; + private final VertexLinker vertexLinker; - public DefaultCarpoolingService(StreetLimitationParametersService streetLimitationParametersService, CarpoolingRepository repository, Graph graph) { + public DefaultCarpoolingService( + StreetLimitationParametersService streetLimitationParametersService, + CarpoolingRepository repository, + Graph graph, + VertexLinker vertexLinker + ) { KristiansandCarpoolingData.populateRepository(repository, graph); this.streetLimitationParametersService = streetLimitationParametersService; this.repository = repository; this.graph = graph; + this.vertexLinker = vertexLinker; } /** @@ -102,8 +99,7 @@ public DefaultCarpoolingService(StreetLimitationParametersService streetLimitati * Create Itineraries for the top 3 results and return * */ - public List route(RouteRequest request) - throws RoutingValidationException { + public List route(RouteRequest request) throws RoutingValidationException { if ( Objects.requireNonNull(request.from()).lat == null || Objects.requireNonNull(request.from()).lng == null @@ -139,9 +135,10 @@ public List route(RouteRequest request) new StreetRequest(StreetMode.CAR), candidate.boardingStop().state.getVertex(), candidate.alightingStop().state.getVertex(), - streetLimitationParametersService.getMaxCarSpeed()); + streetLimitationParametersService.getMaxCarSpeed() + ); - Itinerary itinerary = createItineraryFromRouting(request, candidate, routing); + Itinerary itinerary = CarpoolItineraryMapper.mapToItinerary(request, candidate, routing); if (itinerary != null) { itineraries.add(itinerary); } @@ -156,52 +153,73 @@ private List getCarpoolItineraryCandidates(RouteReque try { temporaryVertices = new TemporaryVerticesContainer( graph, + vertexLinker, request.from(), request.to(), request.journey().access().mode(), - request.journey().egress().mode()); + request.journey().egress().mode() + ); } catch (Exception e) { throw new RuntimeException(e); } - // Prepare access/egress transfers + // Prepare access/egress Collection accessStops = getClosestAreaStopsToVertex( request, request.journey().access(), temporaryVertices.getFromVertices(), null, - repository.getBoardingAreasForVertex()); + repository.getBoardingAreasForVertex() + ); Collection egressStops = getClosestAreaStopsToVertex( request, request.journey().egress(), null, temporaryVertices.getToVertices(), - repository.getAlightingAreasForVertex()); + repository.getAlightingAreasForVertex() + ); - Map tripToBoardingStop = accessStops.stream() - .collect(Collectors.toMap( - stop -> repository.getCarpoolTripByBoardingArea((AreaStop) stop.stop), - stop -> stop - )); + Map tripToBoardingStop = accessStops + .stream() + .collect( + Collectors.toMap( + stop -> repository.getCarpoolTripByBoardingArea((AreaStop) stop.stop), + stop -> stop + ) + ); - Map tripToAlightingStop = egressStops.stream() - .collect(Collectors.toMap( - stop -> repository.getCarpoolTripByAlightingArea((AreaStop) stop.stop), - stop -> stop - )); + Map tripToAlightingStop = egressStops + .stream() + .collect( + Collectors.toMap( + stop -> repository.getCarpoolTripByAlightingArea((AreaStop) stop.stop), + stop -> stop + ) + ); // Find trips that have both boarding and alighting stops - List itineraryCandidates = tripToBoardingStop.keySet().stream() + List itineraryCandidates = tripToBoardingStop + .keySet() + .stream() .filter(tripToAlightingStop::containsKey) - .map(trip -> new CarpoolItineraryCandidate( - trip, - tripToBoardingStop.get(trip), - tripToAlightingStop.get(trip) - )) - .filter(candidate -> - candidate.trip().getStartTime().toInstant().isAfter(candidate.boardingStop().state.getTime()) - && candidate.trip().getStartTime().toInstant().isBefore(candidate.boardingStop().state.getTime().plus(MAX_BOOKING_WINDOW))) + .map(trip -> + new CarpoolItineraryCandidate( + trip, + tripToBoardingStop.get(trip), + tripToAlightingStop.get(trip) + ) + ) + .filter(candidate -> { + // Only include candidates that leave after first possible arrival at the boarding area + // AND leave within the next 2 hours + var tripStartTime = candidate.trip().getStartTime().toInstant(); + var accessArrivalTime = candidate.boardingStop().state.getTime(); + return ( + tripStartTime.isAfter(accessArrivalTime) && + tripStartTime.isBefore(accessArrivalTime.plus(MAX_BOOKING_WINDOW)) + ); + }) .toList(); return itineraryCandidates; } @@ -213,13 +231,16 @@ private List getClosestAreaStopsToVertex( Set destinationVertices, Multimap destinationAreas ) { - var maxAccessEgressDuration = request.preferences().street().accessEgress().maxDuration().valueOf(streetRequest.mode()); + var maxAccessEgressDuration = request + .preferences() + .street() + .accessEgress() + .maxDuration() + .valueOf(streetRequest.mode()); var arriveBy = originVertices == null && destinationVertices != null; var streetSearch = StreetSearchBuilder.of() - .setSkipEdgeStrategy( - new DurationSkipEdgeStrategy<>(maxAccessEgressDuration) - ) + .setSkipEdgeStrategy(new DurationSkipEdgeStrategy<>(maxAccessEgressDuration)) .setDominanceFunction(new DominanceFunctions.MinimumWeight()) .setRequest(request) .setArriveBy(arriveBy) @@ -238,7 +259,8 @@ private List getClosestAreaStopsToVertex( for (State state : spt.getAllStates()) { Vertex targetVertex = state.getVertex(); if ( - targetVertex instanceof StreetVertex streetVertex && destinationAreas.containsKey(streetVertex) + targetVertex instanceof StreetVertex streetVertex && + destinationAreas.containsKey(streetVertex) ) { for (AreaStop areaStop : destinationAreas.get(streetVertex)) { locationsMap.put(areaStop, state); @@ -289,9 +311,7 @@ private GraphPath carpoolRouting( var streetSearch = StreetSearchBuilder.of() .setHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) .setSkipEdgeStrategy( - new DurationSkipEdgeStrategy( - preferences.maxDirectDuration().valueOf(streetRequest.mode()) - ) + new DurationSkipEdgeStrategy(preferences.maxDirectDuration().valueOf(streetRequest.mode())) ) .setDominanceFunction(new DominanceFunctions.MinimumWeight()) .setRequest(request) @@ -304,146 +324,4 @@ private GraphPath carpoolRouting( return paths.getFirst(); } - - /** - * Creates a complete itinerary from A* routing results with proper walking and carpool legs - */ - private Itinerary createItineraryFromRouting(RouteRequest request, CarpoolItineraryCandidate candidate, GraphPath carpoolPath) { - List legs = new ArrayList<>(); - - // 1. Access walking leg (origin to pickup) - Leg accessLeg = createWalkingLegFromPath( - request.journey().access(), - candidate.boardingStop(), - null, - candidate.trip().getStartTime(), - "Walk to pickup" - ); - if (accessLeg != null) { - legs.add(accessLeg); - } - - var drivingEndTime = candidate.trip() - .getStartTime() - .plus( - Duration.between( - carpoolPath.states.getFirst().getTime(), - carpoolPath.states.getLast().getTime() - ) - ); - - // 2. Carpool transit leg (pickup to dropoff) - CarpoolLeg carpoolLeg = CarpoolLeg.of() - .withStartTime(candidate.trip().getStartTime()) - .withEndTime(drivingEndTime) - .withFrom( - createPlaceFromVertex( - carpoolPath.states.getFirst().getVertex(), - "Pickup at " + candidate.trip().getBoardingArea().getName() - ) - ) - .withTo( - createPlaceFromVertex( - carpoolPath.states.getLast().getVertex(), - "Dropoff at " + candidate.trip().getAlightingArea().getName() - ) - ) - .withGeometry( - GeometryUtils.concatenateLineStrings(carpoolPath.edges, Edge::getGeometry) - ) - .withDistanceMeters( - carpoolPath.edges.stream().mapToDouble(Edge::getDistanceMeters).sum() - ) - .withGeneralizedCost((int) carpoolPath.getWeight()) - .build(); - legs.add(carpoolLeg); - - // 3. Egress walking leg (dropoff to destination) - Leg egressLeg = createWalkingLegFromPath( - request.journey().egress(), - candidate.alightingStop(), - drivingEndTime, - null, - "Walk from dropoff" - ); - if (egressLeg != null) { - legs.add(egressLeg); - } - - return Itinerary.ofDirect(legs) - .withGeneralizedCost(Cost.costOfSeconds(accessLeg.generalizedCost() + - carpoolLeg.generalizedCost() + egressLeg.generalizedCost())) - .build(); - } - - /** - * Creates a walking leg from a GraphPath with proper geometry and timing. - * This reuses the same pattern as OTP's GraphPathToItineraryMapper but simplified - * for carpooling service use. - */ - @Nullable - private Leg createWalkingLegFromPath( - StreetRequest streetRequest, - NearbyStop nearbyStop, - ZonedDateTime legStartTime, - ZonedDateTime legEndTime, - String name - ) { - if (nearbyStop == null || nearbyStop.edges.isEmpty()) { - return null; - } - - var graphPath = new GraphPath<>(nearbyStop.state); - - var firstState = graphPath.states.getFirst(); - var lastState = graphPath.states.getLast(); - - List edges = nearbyStop.edges; - - if (edges.isEmpty()) { - return null; - } - - // Create geometry from edges - LineString geometry = GeometryUtils.concatenateLineStrings(edges, Edge::getGeometry); - - var legDuration = Duration.between(firstState.getTime(), lastState.getTime()); - if (legStartTime != null && legEndTime == null) { - legEndTime = legStartTime.plus(legDuration); - } else if (legEndTime != null && legStartTime == null) { - legStartTime = legEndTime.minus(legDuration); - } - - // Build the walking leg - return StreetLeg.of() - .withMode(StreetModeToTransferTraverseModeMapper.map(streetRequest.mode() == StreetMode.NOT_SET ? StreetMode.WALK : streetRequest.mode())) - .withStartTime(legStartTime) - .withEndTime(legEndTime) - .withFrom(createPlaceFromVertex(firstState.getVertex(), name + " start")) - .withTo(createPlaceFromVertex(lastState.getVertex(), name + " end")) - .withDistanceMeters(nearbyStop.distance) - .withGeneralizedCost((int) (lastState.getWeight() - firstState.getWeight())) - .withGeometry(geometry) - .build(); - } - - /** - * Creates a Place from a State, similar to GraphPathToItineraryMapper.makePlace - * but simplified for carpooling service use. - */ - private Place createPlaceFromVertex(Vertex vertex, String defaultName) { - I18NString name = vertex.getName(); - - // Use intersection name for street vertices to get better names - if (vertex instanceof StreetVertex && !(vertex instanceof TemporaryStreetLocation)) { - name = ((StreetVertex) vertex).getIntersectionName(); - } - - // If no name available, use default - if (name == null || name.toString().trim().isEmpty()) { - name = new NonLocalizedString(defaultName); - } - - return Place.normal(vertex, name); - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java index 3d10100d649..dcd17c86dd8 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java @@ -6,4 +6,4 @@ public record CarpoolItineraryCandidate( CarpoolTrip trip, NearbyStop boardingStop, NearbyStop alightingStop -) {} \ No newline at end of file +) {} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java index 6d86e9780a6..bcd5cd82121 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java @@ -10,7 +10,7 @@ import org.locationtech.jts.geom.LineString; import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.model.PickDrop; -import org.opentripplanner.model.fare.FareProductUse; +import org.opentripplanner.model.fare.FareOffer; import org.opentripplanner.model.plan.Emission; import org.opentripplanner.model.plan.Leg; import org.opentripplanner.model.plan.Place; @@ -50,8 +50,6 @@ public class CarpoolLeg implements Leg { private final Emission emissionPerPerson; - private final List fareProducts; - private final Place from; private final Place to; @@ -65,7 +63,6 @@ public class CarpoolLeg implements Leg { this.endTime = Objects.requireNonNull(builder.endTime()); this.generalizedCost = builder.generalizedCost(); this.transitAlerts = Set.copyOf(builder.alerts()); - this.fareProducts = List.copyOf(builder.fareProducts()); this.emissionPerPerson = builder.emissionPerPerson(); this.from = builder.from(); this.to = builder.to(); @@ -394,6 +391,11 @@ public Set fareZones() { return Leg.super.fareZones(); } + @Override + public List fareOffers() { + return List.of(); + } + @Nullable @Override public Emission emissionPerPerson() { @@ -418,11 +420,6 @@ public String vehicleRentalNetwork() { return Leg.super.vehicleRentalNetwork(); } - @Override - public List fareProducts() { - return fareProducts; - } - public TransitMode mode() { return TransitMode.CARPOOL; } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java index 5536b6f7e8a..41305b51e6e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java @@ -1,13 +1,10 @@ package org.opentripplanner.ext.carpooling.model; import java.time.ZonedDateTime; -import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; -import java.util.List; import java.util.Set; import org.locationtech.jts.geom.LineString; -import org.opentripplanner.model.fare.FareProductUse; import org.opentripplanner.model.plan.Emission; import org.opentripplanner.model.plan.Place; import org.opentripplanner.routing.alertpatch.TransitAlert; @@ -18,7 +15,6 @@ public class CarpoolLegBuilder { private ZonedDateTime endTime; private int generalizedCost; private Set transitAlerts = new HashSet<>(); - private List fareProducts = new ArrayList<>(); private Emission emissionPerPerson; private Place from; private Place to; @@ -32,7 +28,6 @@ public class CarpoolLegBuilder { endTime = original.endTime(); generalizedCost = original.generalizedCost(); transitAlerts = original.listTransitAlerts(); - fareProducts = original.fareProducts(); emissionPerPerson = original.emissionPerPerson(); from = original.from(); to = original.to(); @@ -76,15 +71,6 @@ public Set alerts() { return transitAlerts; } - public CarpoolLegBuilder withFareProducts(List allUses) { - this.fareProducts = List.copyOf(allUses); - return this; - } - - public List fareProducts() { - return fareProducts; - } - public CarpoolLegBuilder withEmissionPerPerson(Emission emissionPerPerson) { this.emissionPerPerson = emissionPerPerson; return this; diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java new file mode 100644 index 00000000000..123308cbd7e --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -0,0 +1,97 @@ +package org.opentripplanner.ext.carpooling.updater; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.site.AreaStop; +import uk.org.siri.siri21.EstimatedCall; +import uk.org.siri.siri21.EstimatedVehicleJourney; + +public class CarpoolSiriMapper { + + private static final String FEED_ID = "ENT"; + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + private static final AtomicInteger COUNTER = new AtomicInteger(0); + + public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { + var calls = journey.getEstimatedCalls().getEstimatedCalls(); + if (calls.size() != 2) { + throw new IllegalArgumentException("Carpool trips must have exactly 2 stops for now."); + } + + var boardingCall = calls.getFirst(); + var alightingCall = calls.getLast(); + + String lineRef = journey.getLineRef().getValue(); + String tripId = lineRef.substring(lineRef.lastIndexOf(':') + 1); + + AreaStop boardingArea = buildAreaStop(boardingCall, tripId + "_boarding"); + AreaStop alightingArea = buildAreaStop(alightingCall, tripId + "_alighting"); + + ZonedDateTime startTime = boardingCall.getExpectedDepartureTime() != null + ? boardingCall.getExpectedDepartureTime() + : boardingCall.getAimedDepartureTime(); + + ZonedDateTime endTime = alightingCall.getExpectedArrivalTime() != null + ? alightingCall.getExpectedArrivalTime() + : alightingCall.getAimedArrivalTime(); + + String provider = journey.getOperatorRef().getValue(); + + return new CarpoolTripBuilder(new FeedScopedId(FEED_ID, tripId)) + .withBoardingArea(boardingArea) + .withAlightingArea(alightingArea) + .withStartTime(startTime) + .withEndTime(endTime) + .withProvider(provider) + .withAvailableSeats(1) // Default value, could be enhanced if data available + .build(); + } + + private AreaStop buildAreaStop(EstimatedCall call, String id) { + var stopAssignments = call.getDepartureStopAssignments(); + if (stopAssignments == null || stopAssignments.size() != 1) { + throw new IllegalArgumentException("Expected exactly one stop assignment for call: " + call); + } + var flexibleArea = stopAssignments.getFirst().getExpectedFlexibleArea(); + + if (flexibleArea == null || flexibleArea.getPolygon() == null) { + throw new IllegalArgumentException("Missing flexible area for stop"); + } + + var polygon = createPolygonFromGml(flexibleArea.getPolygon()); + + return AreaStop.of(new FeedScopedId(FEED_ID, id), COUNTER::getAndIncrement) + .withName(I18NString.of(call.getStopPointNames().getFirst().getValue())) + .withGeometry(polygon) + .build(); + } + + private Polygon createPolygonFromGml(net.opengis.gml._3.PolygonType gmlPolygon) { + var abstractRing = gmlPolygon.getExterior().getAbstractRing().getValue(); + + if (!(abstractRing instanceof net.opengis.gml._3.LinearRingType)) { + throw new IllegalArgumentException("Expected LinearRingType for polygon exterior"); + } + + var linearRing = (net.opengis.gml._3.LinearRingType) abstractRing; + List values = linearRing.getPosList().getValue(); + + // Convert to JTS coordinates (lon lat pairs) + Coordinate[] coords = new Coordinate[values.size() / 2]; + for (int i = 0; i < values.size(); i += 2) { + coords[i / 2] = new Coordinate(values.get(i), values.get(i + 1)); + } + + LinearRing shell = GEOMETRY_FACTORY.createLinearRing(coords); + return GEOMETRY_FACTORY.createPolygon(shell); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index 613a73cf031..cd4f7451ef5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -32,6 +32,8 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { private final CarpoolingRepository repository; + private final CarpoolSiriMapper mapper; + /** * Feed id that is used for the trip ids in the TripUpdates */ @@ -56,6 +58,8 @@ public SiriETCarpoolingUpdater( LOG.info("Creating SIRI-ET updater running every {}: {}", pollingPeriod(), updateSource); this.metricsConsumer = TripUpdateMetrics.streaming(config); + + this.mapper = new CarpoolSiriMapper(); } /** @@ -77,6 +81,21 @@ public void runPolling() { List etds = serviceDelivery.getEstimatedTimetableDeliveries(); if (etds != null) { + for (EstimatedTimetableDeliveryStructure etd : etds) { + var ejvfs = etd.getEstimatedJourneyVersionFrames(); + for (var ejvf : ejvfs) { + if (ejvf.getEstimatedVehicleJourneies() == null) { + LOG.warn("Received an empty EstimatedJourneyVersionFrame, skipping"); + continue; + } + ejvf + .getEstimatedVehicleJourneies() + .forEach(ejv -> { + var carpoolTrip = mapper.mapSiriToCarpoolTrip(ejv); + repository.addCarpoolTrip(carpoolTrip); + }); + } + } LOG.info("Received {} estimated timetable deliveries", etds.size()); } } diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java index 28a959515e0..fc28820da3a 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java @@ -3,6 +3,7 @@ import jakarta.ws.rs.core.Application; import javax.annotation.Nullable; import org.opentripplanner.datastore.api.DataSource; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.emission.EmissionRepository; import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayRepository; import org.opentripplanner.ext.stopconsolidation.StopConsolidationRepository; @@ -192,8 +193,7 @@ private void setupTransitRoutingServer() { vehicleRentalRepository(), vehicleParkingRepository(), timetableRepository(), - //TODO Carpooling inject car pooling repository - null, + carpoolingRepository(), snapshotManager(), routerConfig().updaterConfig() ); @@ -269,6 +269,10 @@ public TimetableRepository timetableRepository() { return factory.timetableRepository(); } + public CarpoolingRepository carpoolingRepository() { + return factory.carpoolingRepository(); + } + public DataImportIssueSummary dataImportIssueSummary() { return factory.dataImportIssueSummary(); } diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java index ce080169417..3114fc180cb 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java @@ -9,6 +9,7 @@ import org.opentripplanner.apis.gtfs.configure.SchemaModule; import org.opentripplanner.apis.transmodel.configure.TransmodelSchema; import org.opentripplanner.apis.transmodel.configure.TransmodelSchemaModule; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.carpooling.configure.CarpoolingModule; import org.opentripplanner.ext.emission.EmissionRepository; @@ -111,6 +112,9 @@ public interface ConstructApplicationFactory { @Nullable CarpoolingService carpoolingService(); + @Nullable + CarpoolingRepository carpoolingRepository(); + @Nullable EmissionRepository emissionRepository(); diff --git a/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql b/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql index c55f3815b7f..0f80b60872f 100644 --- a/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql +++ b/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql @@ -1704,6 +1704,7 @@ enum Mode { bus cableway car + carpool coach foot funicular From c7f4b671d114356ee7f4a6c5ef3c1c3dbf79fa59 Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 21 Aug 2025 15:53:31 +0200 Subject: [PATCH 04/40] Formatting fix. --- .../data/KristiansandCarpoolingData.java | 1 - .../internal/CarpoolItineraryMapper.java | 189 ++-------- .../internal/DefaultCarpoolingRepository.java | 8 +- .../internal/DefaultCarpoolingService.java | 334 +++++++++--------- .../internal/ViaCarpoolCandidate.java | 26 ++ .../model/CarpoolItineraryCandidate.java | 9 - .../ext/carpooling/model/CarpoolTrip.java | 45 ++- .../carpooling/model/CarpoolTripBuilder.java | 45 +-- .../carpooling/updater/CarpoolSiriMapper.java | 181 +++++++++- .../updater/SiriETCarpoolingUpdater.java | 19 +- .../ConstructApplicationFactory.java | 1 - .../configure/UpdaterConfigurator.java | 16 +- 12 files changed, 483 insertions(+), 391 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java index c8c88a3441e..90d6694ea3c 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java @@ -137,7 +137,6 @@ private static CarpoolTrip createCarpoolTrip( .withAlightingArea(alightingArea) .withStartTime(startDateTime) .withEndTime(endDateTime) - .withTrip(null) .withProvider(provider) .withAvailableSeats(availableSeats) .build(); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java index b48ea062323..cf48b35f9e1 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -1,182 +1,69 @@ package org.opentripplanner.ext.carpooling.internal; import java.time.Duration; -import java.time.ZonedDateTime; -import java.util.ArrayList; +import java.time.ZoneId; import java.util.List; -import javax.annotation.Nullable; -import org.locationtech.jts.geom.LineString; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.ext.carpooling.model.CarpoolItineraryCandidate; import org.opentripplanner.ext.carpooling.model.CarpoolLeg; import org.opentripplanner.framework.geometry.GeometryUtils; -import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.framework.model.Cost; import org.opentripplanner.model.plan.Itinerary; -import org.opentripplanner.model.plan.Leg; import org.opentripplanner.model.plan.Place; -import org.opentripplanner.model.plan.leg.StreetLeg; -import org.opentripplanner.routing.algorithm.mapping.StreetModeToTransferTraverseModeMapper; import org.opentripplanner.routing.api.request.RouteRequest; -import org.opentripplanner.routing.api.request.StreetMode; -import org.opentripplanner.routing.api.request.request.StreetRequest; -import org.opentripplanner.routing.graphfinder.NearbyStop; import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.StreetVertex; -import org.opentripplanner.street.model.vertex.TemporaryStreetLocation; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.state.State; public class CarpoolItineraryMapper { /** - * Creates a complete itinerary from A* routing results with proper access/egress legs and carpool legs + * Creates an itinerary from a viable via-route carpool candidate. + * This uses the shared route segment as the main carpool leg. */ - public static Itinerary mapToItinerary( + public static Itinerary mapViaRouteToItinerary( RouteRequest request, - CarpoolItineraryCandidate candidate, - GraphPath carpoolPath + ViaCarpoolCandidate candidate ) { - List legs = new ArrayList<>(); - - // 1. Access walking leg (origin to pickup) - Leg accessLeg = accessEgressLeg( - request.journey().access(), - candidate.boardingStop(), - null, - candidate.trip().getStartTime(), - "Walk to pickup" + var pickupDuration = Duration.between( + candidate.pickupRoute().states.getFirst().getTime(), + candidate.pickupRoute().states.getLast().getTime() ); - if (accessLeg != null) { - legs.add(accessLeg); - } - var drivingEndTime = candidate + var driverPickupTime = candidate .trip() - .getStartTime() - .plus( - Duration.between( - carpoolPath.states.getFirst().getTime(), - carpoolPath.states.getLast().getTime() - ) - ); - - // 2. Carpool transit leg (pickup to dropoff) - CarpoolLeg carpoolLeg = CarpoolLeg.of() - .withStartTime(candidate.trip().getStartTime()) - .withEndTime(drivingEndTime) - .withFrom( - createPlaceFromVertex( - carpoolPath.states.getFirst().getVertex(), - "Pickup at " + candidate.trip().getBoardingArea().getName() - ) - ) - .withTo( - createPlaceFromVertex( - carpoolPath.states.getLast().getVertex(), - "Dropoff at " + candidate.trip().getAlightingArea().getName() - ) - ) - .withGeometry(GeometryUtils.concatenateLineStrings(carpoolPath.edges, Edge::getGeometry)) - .withDistanceMeters(carpoolPath.edges.stream().mapToDouble(Edge::getDistanceMeters).sum()) - .withGeneralizedCost((int) carpoolPath.getWeight()) - .build(); - legs.add(carpoolLeg); - - // 3. Egress walking leg (dropoff to destination) - Leg egressLeg = accessEgressLeg( - request.journey().egress(), - candidate.alightingStop(), - carpoolLeg.endTime(), - null, - "Walk from dropoff" + .startTime() + .plus(pickupDuration); + + // Main carpool leg (passenger origin to destination via shared route) + // Start time is max of request dateTime and driverPickupTime + var startTime = request.dateTime().isAfter(driverPickupTime.toInstant()) + ? request.dateTime().atZone(ZoneId.of("Europe/Oslo")) + : driverPickupTime; + + var carpoolDuration = Duration.between( + candidate.sharedRoute().states.getFirst().getTime(), + candidate.sharedRoute().states.getLast().getTime() ); - if (egressLeg != null) { - legs.add(egressLeg); - } - - return Itinerary.ofDirect(legs) - .withGeneralizedCost( - Cost.costOfSeconds( - accessLeg.generalizedCost() + carpoolLeg.generalizedCost() + egressLeg.generalizedCost() - ) - ) - .build(); - } - - /** - * Creates a walking leg from a GraphPath with proper geometry and timing. - * This reuses the same pattern as OTP's GraphPathToItineraryMapper but simplified - * for carpooling service use. - */ - @Nullable - private static Leg accessEgressLeg( - StreetRequest streetRequest, - NearbyStop nearbyStop, - ZonedDateTime legStartTime, - ZonedDateTime legEndTime, - String name - ) { - if (nearbyStop == null || nearbyStop.edges.isEmpty()) { - return null; - } - - var graphPath = new GraphPath<>(nearbyStop.state); - - var firstState = graphPath.states.getFirst(); - var lastState = graphPath.states.getLast(); - - List edges = nearbyStop.edges; - if (edges.isEmpty()) { - return null; - } + var endTime = startTime.plus(carpoolDuration); - // Create geometry from edges - LineString geometry = GeometryUtils.concatenateLineStrings(edges, Edge::getGeometry); + var fromVertex = candidate.passengerOrigin().iterator().next(); + var toVertex = candidate.passengerDestination().iterator().next(); - var legDuration = Duration.between(firstState.getTime(), lastState.getTime()); - if (legStartTime != null && legEndTime == null) { - legEndTime = legStartTime.plus(legDuration); - } else if (legEndTime != null && legStartTime == null) { - legStartTime = legEndTime.minus(legDuration); - } - - // Build the walking leg - return StreetLeg.of() - .withMode( - StreetModeToTransferTraverseModeMapper.map( - streetRequest.mode() == StreetMode.NOT_SET ? StreetMode.WALK : streetRequest.mode() - ) + CarpoolLeg carpoolLeg = CarpoolLeg.of() + .withStartTime(startTime) + .withEndTime(endTime) + .withFrom(Place.normal(fromVertex, new NonLocalizedString("Carpool boarding"))) + .withTo(Place.normal(toVertex, new NonLocalizedString("Carpool alighting"))) + .withGeometry( + GeometryUtils.concatenateLineStrings(candidate.sharedRoute().edges, Edge::getGeometry) + ) + .withDistanceMeters( + candidate.sharedRoute().edges.stream().mapToDouble(Edge::getDistanceMeters).sum() ) - .withStartTime(legStartTime) - .withEndTime(legEndTime) - .withFrom(createPlaceFromVertex(firstState.getVertex(), name + " start")) - .withTo(createPlaceFromVertex(lastState.getVertex(), name + " end")) - .withDistanceMeters(nearbyStop.distance) - .withGeneralizedCost((int) (lastState.getWeight() - firstState.getWeight())) - .withGeometry(geometry) + .withGeneralizedCost((int) candidate.sharedRoute().getWeight()) .build(); - } - - /** - * Creates a Place from a State, similar to GraphPathToItineraryMapper.makePlace - * but simplified for carpooling service use. - */ - private static Place createPlaceFromVertex(Vertex vertex, String defaultName) { - I18NString name = vertex.getName(); - // Use intersection name for street vertices to get better names - if (vertex instanceof StreetVertex && !(vertex instanceof TemporaryStreetLocation)) { - name = ((StreetVertex) vertex).getIntersectionName(); - } - - // If no name available, use default - if (name == null || name.toString().trim().isEmpty()) { - name = new NonLocalizedString(defaultName); - } - - return Place.normal(vertex, name); + return Itinerary.ofDirect(List.of(carpoolLeg)) + .withGeneralizedCost(Cost.costOfSeconds(carpoolLeg.generalizedCost())) + .build(); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java index 55a1d91b4bb..361556de15e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java @@ -45,8 +45,8 @@ public Collection getCarpoolTrips() { public void addCarpoolTrip(CarpoolTrip trip) { trips.put(trip.getId(), trip); - var boardingArea = trip.getBoardingArea(); - var alightingArea = trip.getAlightingArea(); + var boardingArea = trip.boardingArea(); + var alightingArea = trip.alightingArea(); boardingAreas.put(boardingArea, trip); alightingAreas.put(alightingArea, trip); @@ -58,9 +58,7 @@ public void addCarpoolTrip(CarpoolTrip trip) { streetVerticesWithinAreaStop(alightingArea).forEach(v -> { alightingAreasByVertex.put(v, alightingArea); }); - LOG.info("Added carpooling trip for start time: {}", - trip.getStartTime() - ); + LOG.info("Added carpooling trip for start time: {}", trip.startTime()); } private List streetVerticesWithinAreaStop(AreaStop stop) { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java index 6d4e4f8bad8..fb5a5d62f84 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java @@ -1,17 +1,12 @@ package org.opentripplanner.ext.carpooling.internal; -import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.Multimap; import java.time.Duration; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; import javax.annotation.Nullable; import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; @@ -19,8 +14,8 @@ import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.carpooling.data.KristiansandCarpoolingData; -import org.opentripplanner.ext.carpooling.model.CarpoolItineraryCandidate; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.model.GenericLocation; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; @@ -31,11 +26,8 @@ import org.opentripplanner.routing.api.response.RoutingErrorCode; import org.opentripplanner.routing.error.RoutingValidationException; import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.routing.graphfinder.NearbyStop; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.StreetVertex; -import org.opentripplanner.street.model.vertex.TemporaryStreetLocation; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.StreetSearchBuilder; import org.opentripplanner.street.search.TemporaryVerticesContainer; @@ -43,7 +35,6 @@ import org.opentripplanner.street.search.strategy.DominanceFunctions; import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; import org.opentripplanner.street.service.StreetLimitationParametersService; -import org.opentripplanner.transit.model.site.AreaStop; public class DefaultCarpoolingService implements CarpoolingService { @@ -69,34 +60,30 @@ public DefaultCarpoolingService( } /** - * TERMINOLOGY - * - Boarding and alighting area stops - * - * - * ALGORITHM OUTLINE + * VIA-SEARCH ALGORITHM FOR CARPOOLING INTEGRATION * *
-   *   DIRECT_DISTANCE = SphericalDistanceLibrary.fastDistance(fromLocation, toLocation)
-   *   // 3000m is about 45 minutes of walking
-   *   MAX_WALK_DISTANCE = max(DIRECT_DISTANCE, 3000m)
-   *   MAX_COST = MAX_WALK_DISTANCE * walkReluctance + DIRECT_DISTANCE - MAX_WALK_DISTANCE
-   *
-   * Search for access / egress candidates (AreaStops) using
-   * - accessDistance = SphericalDistanceLibrary.fastDistance(fromLocation, stop.center);
-   * - Drop candidates where accessDistance greater then MAX_WALK_DISTANCE and is not within time constraints
-   * - egressDistance = SphericalDistanceLibrary.fastDistance(toLocation, stop.center);
-   * - Drop candidates where (accessDistance + egressDistance) greater then MAX_WALK_DISTANCE (no time check)
-   * - Sort candidates on estimated cost, where we use direct distance instead of actual distance
+   * The core challenge of carpooling integration is matching passengers with drivers
+   * based on route compatibility. This is fundamentally a via-point routing problem
+   * where we need to determine if a driver's journey from A→B can accommodate a
+   * passenger's journey from C→D within the driver's stated deviation tolerance.
    *
-   * FOR EACH CANDIDATE (C)
-   * - Use AStar to find the actual distance for:
-   *   - access path
-   *   - transit path
-   *   - egress path
-   * - Drop candidates where (access+carpool+egress) cost > MAX_COST
-   * [- Abort when no more optimal results can be obtained (pri2)]
+   * Algorithm Overview:
+   * 1. Driver has a baseline route from origin A to destination B
+   * 2. Driver specifies a deviation tolerance (deviationBudget in CarpoolTrip)
+   * 3. Passenger requests travel from origin C to destination D
+   * 4. Algorithm checks if route A→C→D→B stays within constraint:
+   *    total_time ≤ baseline_time + deviation_tolerance
    *
-   * Create Itineraries for the top 3 results and return
+   * Multi-Stage Processing:
+   * Stage 1: Get all available carpool trips from repository
+   * Stage 2: For each trip, calculate baseline route time (A→B)
+   * Stage 3: Calculate via-route segments:
+   *   - A→C: Driver's detour to pickup point
+   *   - C→D: Shared journey segment
+   *   - D→B: Driver's continuation to final destination
+   * Stage 4: Feasibility check - compare total time vs baseline + deviationBudget
+   * Stage 5: Return viable matches ranked by efficiency
    * 
*/ public List route(RouteRequest request) throws RoutingValidationException { @@ -117,28 +104,51 @@ public List route(RouteRequest request) throws RoutingValidationExcep ); } - List itineraryCandidates = getCarpoolItineraryCandidates(request); - if (itineraryCandidates.isEmpty()) { - // No relevant carpool trips found within the next 2 hours, return empty list + // Get all available carpool trips from repository + List availableTrips = repository + .getCarpoolTrips() + .stream() + .filter(trip -> { + // Only include trips that start within the next 2 hours + var tripStartTime = trip.startTime().toInstant(); + var requestTime = request.dateTime(); + return ( + // Currently we only include trips that start after the request time + // We should also consider trips that start before request time and make it to the + // pickup location on time. + tripStartTime.isAfter(requestTime) && + tripStartTime.isBefore(requestTime.plus(MAX_BOOKING_WINDOW)) + ); + }) + .toList(); + + if (availableTrips.isEmpty()) { return Collections.emptyList(); } - // Perform A* routing for the top candidates and create itineraries - List itineraries = new ArrayList<>(); - int maxResults = Math.min(itineraryCandidates.size(), DEFAULT_MAX_CARPOOL_RESULTS); - - for (int i = 0; i < maxResults; i++) { - CarpoolItineraryCandidate candidate = itineraryCandidates.get(i); + // Evaluate each carpool trip using via-search algorithm + List viableCandidates = new ArrayList<>(); - GraphPath routing = carpoolRouting( + for (CarpoolTrip trip : availableTrips) { + ViaCarpoolCandidate candidate = evaluateViaRouteForTrip( request, - new StreetRequest(StreetMode.CAR), - candidate.boardingStop().state.getVertex(), - candidate.alightingStop().state.getVertex(), - streetLimitationParametersService.getMaxCarSpeed() + trip ); + if (candidate != null) { + viableCandidates.add(candidate); + } + } + + // Sort candidates by efficiency (additional time for driver) + viableCandidates.sort(Comparator.comparing(ViaCarpoolCandidate::viaDeviation)); - Itinerary itinerary = CarpoolItineraryMapper.mapToItinerary(request, candidate, routing); + // Create itineraries for top results + List itineraries = new ArrayList<>(); + int maxResults = Math.min(viableCandidates.size(), DEFAULT_MAX_CARPOOL_RESULTS); + + for (int i = 0; i < maxResults; i++) { + ViaCarpoolCandidate candidate = viableCandidates.get(i); + Itinerary itinerary = CarpoolItineraryMapper.mapViaRouteToItinerary(request, candidate); if (itinerary != null) { itineraries.add(itinerary); } @@ -147,151 +157,129 @@ public List route(RouteRequest request) throws RoutingValidationExcep return itineraries; } - private List getCarpoolItineraryCandidates(RouteRequest request) { - TemporaryVerticesContainer temporaryVertices; + /** + * Evaluate a single carpool trip using the via-search algorithm. + * Returns a viable candidate if the route A→C→D→B stays within the deviationBudget. + */ + private ViaCarpoolCandidate evaluateViaRouteForTrip( + RouteRequest request, + CarpoolTrip trip + ) { + + TemporaryVerticesContainer acTempVertices; + try { + acTempVertices = new TemporaryVerticesContainer( + graph, + vertexLinker, + GenericLocation.fromCoordinate(trip.boardingArea().getLat(), trip.boardingArea().getLon()), + GenericLocation.fromCoordinate(request.from().lat, request.from().lng), + StreetMode.CAR, // We'll route by car for all segments + StreetMode.CAR + ); + } catch (Exception e) { + throw new RuntimeException(e); + } + + TemporaryVerticesContainer cdTempVertices; + try { + cdTempVertices = new TemporaryVerticesContainer( + graph, + vertexLinker, + GenericLocation.fromCoordinate(request.from().lat, request.from().lng), + GenericLocation.fromCoordinate(request.to().lat, request.to().lng), + StreetMode.CAR, // We'll route by car for all segments + StreetMode.CAR + ); + } catch (Exception e) { + throw new RuntimeException(e); + } + TemporaryVerticesContainer dbTempVertices; try { - temporaryVertices = new TemporaryVerticesContainer( + dbTempVertices = new TemporaryVerticesContainer( graph, vertexLinker, - request.from(), - request.to(), - request.journey().access().mode(), - request.journey().egress().mode() + GenericLocation.fromCoordinate(request.to().lat, request.to().lng), + GenericLocation.fromCoordinate(trip.alightingArea().getLat(), trip.alightingArea().getLon()), + StreetMode.CAR, // We'll route by car for all segments + StreetMode.CAR ); } catch (Exception e) { throw new RuntimeException(e); } - // Prepare access/egress - Collection accessStops = getClosestAreaStopsToVertex( + // Calculate via-route segments: A→C→D→B + GraphPath pickupRoute = performCarRouting( request, - request.journey().access(), - temporaryVertices.getFromVertices(), - null, - repository.getBoardingAreasForVertex() + acTempVertices.getFromVertices(), + acTempVertices.getToVertices() ); - - Collection egressStops = getClosestAreaStopsToVertex( + GraphPath sharedRoute = performCarRouting( request, - request.journey().egress(), - null, - temporaryVertices.getToVertices(), - repository.getAlightingAreasForVertex() + cdTempVertices.getFromVertices(), + cdTempVertices.getToVertices() + ); + GraphPath dropoffRoute = performCarRouting( + request, + dbTempVertices.getFromVertices(), + dbTempVertices.getToVertices() ); - Map tripToBoardingStop = accessStops - .stream() - .collect( - Collectors.toMap( - stop -> repository.getCarpoolTripByBoardingArea((AreaStop) stop.stop), - stop -> stop - ) - ); + if (pickupRoute == null || sharedRoute == null || dropoffRoute == null) { + return null; // Failed to calculate some segments + } - Map tripToAlightingStop = egressStops - .stream() - .collect( - Collectors.toMap( - stop -> repository.getCarpoolTripByAlightingArea((AreaStop) stop.stop), - stop -> stop - ) - ); + // Calculate total travel times + var viaDuration = + routeDuration(pickupRoute) + .plus(routeDuration(sharedRoute)) + .plus(routeDuration(dropoffRoute)); - // Find trips that have both boarding and alighting stops - List itineraryCandidates = tripToBoardingStop - .keySet() - .stream() - .filter(tripToAlightingStop::containsKey) - .map(trip -> - new CarpoolItineraryCandidate( - trip, - tripToBoardingStop.get(trip), - tripToAlightingStop.get(trip) - ) - ) - .filter(candidate -> { - // Only include candidates that leave after first possible arrival at the boarding area - // AND leave within the next 2 hours - var tripStartTime = candidate.trip().getStartTime().toInstant(); - var accessArrivalTime = candidate.boardingStop().state.getTime(); - return ( - tripStartTime.isAfter(accessArrivalTime) && - tripStartTime.isBefore(accessArrivalTime.plus(MAX_BOOKING_WINDOW)) - ); - }) - .toList(); - return itineraryCandidates; + // Check if within deviation budget + var deviationDuration = viaDuration.minus(trip.tripDuration()); + if (deviationDuration.compareTo(trip.deviationBudget()) > 0) { + return null; // Exceeds deviation budget + } + + return new ViaCarpoolCandidate( + trip, + pickupRoute, + sharedRoute, + deviationDuration, + viaDuration, + cdTempVertices.getFromVertices(), + cdTempVertices.getToVertices() + ); } - private List getClosestAreaStopsToVertex( + /** + * Performs car routing between two vertices. + */ + private GraphPath performCarRouting( RouteRequest request, - StreetRequest streetRequest, - Set originVertices, - Set destinationVertices, - Multimap destinationAreas + Set from, + Set to ) { - var maxAccessEgressDuration = request - .preferences() - .street() - .accessEgress() - .maxDuration() - .valueOf(streetRequest.mode()); - var arriveBy = originVertices == null && destinationVertices != null; - - var streetSearch = StreetSearchBuilder.of() - .setSkipEdgeStrategy(new DurationSkipEdgeStrategy<>(maxAccessEgressDuration)) - .setDominanceFunction(new DominanceFunctions.MinimumWeight()) - .setRequest(request) - .setArriveBy(arriveBy) - .setStreetRequest(streetRequest) - .setFrom(originVertices) - .setTo(destinationVertices); - - var spt = streetSearch.getShortestPathTree(); - - if (spt == null) { - return Collections.emptyList(); - } - - // Get the reachable AreaStops from the vertices in the SPT - Multimap locationsMap = ArrayListMultimap.create(); - for (State state : spt.getAllStates()) { - Vertex targetVertex = state.getVertex(); - if ( - targetVertex instanceof StreetVertex streetVertex && - destinationAreas.containsKey(streetVertex) - ) { - for (AreaStop areaStop : destinationAreas.get(streetVertex)) { - locationsMap.put(areaStop, state); - } - } - } - - // Map the minimum reachable state for each AreaStop and the AreaStop to NearbyStop - List stopsFound = new ArrayList<>(); - for (var locationStates : locationsMap.asMap().entrySet()) { - AreaStop areaStop = locationStates.getKey(); - State min = getMinState(locationStates); - - stopsFound.add(NearbyStop.nearbyStopForState(min, areaStop)); - } - return stopsFound; + return carpoolRouting( + request, + new StreetRequest(StreetMode.CAR), + from, + to, + streetLimitationParametersService.getMaxCarSpeed() + ); } - private static State getMinState(Map.Entry> locationStates) { - Collection states = locationStates.getValue(); - // Select the vertex from all vertices that are reachable per AreaStop by taking - // the minimum walking distance - State min = Collections.min(states, Comparator.comparing(State::getWeight)); - - // If the best state for this AreaStop is a SplitterVertex, we want to get the - // TemporaryStreetLocation instead. This allows us to reach SplitterVertices in both - // directions when routing later. - if (min.getBackState().getVertex() instanceof TemporaryStreetLocation) { - min = min.getBackState(); + /** + * Calculate the travel time in seconds for a given route. + */ + private Duration routeDuration(GraphPath route) { + if (route == null || route.states.isEmpty()) { + return Duration.ZERO; } - return min; + return Duration.between( + route.states.getFirst().getTime(), + route.states.getLast().getTime() + ); } /** @@ -302,8 +290,8 @@ private static State getMinState(Map.Entry> location private GraphPath carpoolRouting( RouteRequest request, StreetRequest streetRequest, - Vertex from, - Vertex to, + Set from, + Set to, float maxCarSpeed ) { StreetPreferences preferences = request.preferences().street(); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java new file mode 100644 index 00000000000..eb21397839c --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java @@ -0,0 +1,26 @@ +package org.opentripplanner.ext.carpooling.internal; + +import java.time.Duration; +import java.util.Set; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +/** + * Represents a viable carpool match with via-route timing information + */ +public record ViaCarpoolCandidate( + CarpoolTrip trip, + GraphPath pickupRoute, // A→C (driver to passenger pickup) + GraphPath sharedRoute, // C→D (shared journey) + Duration baselineDuration, + Duration viaDuration, + Set passengerOrigin, + Set passengerDestination +) { + public Duration viaDeviation() { + return viaDuration.minus(baselineDuration); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java deleted file mode 100644 index dcd17c86dd8..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolItineraryCandidate.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.opentripplanner.ext.carpooling.model; - -import org.opentripplanner.routing.graphfinder.NearbyStop; - -public record CarpoolItineraryCandidate( - CarpoolTrip trip, - NearbyStop boardingStop, - NearbyStop alightingStop -) {} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 17014019d6e..4b2c966bd9a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -1,12 +1,12 @@ package org.opentripplanner.ext.carpooling.model; +import java.time.Duration; import java.time.ZonedDateTime; import javax.annotation.Nullable; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.framework.LogInfo; import org.opentripplanner.transit.model.framework.TransitBuilder; import org.opentripplanner.transit.model.site.AreaStop; -import org.opentripplanner.transit.model.timetable.Trip; /** * A carpool trip is defined by two area stops and a start time, in addition to all the other fields @@ -21,49 +21,58 @@ public class CarpoolTrip private final AreaStop alightingArea; private final ZonedDateTime startTime; private final ZonedDateTime endTime; - private final Trip trip; private final String provider; + + // The amount of time the trip can deviate from the scheduled time in order to pick up or drop off + // a passenger. + private final Duration deviationBudget; private final int availableSeats; public CarpoolTrip(CarpoolTripBuilder builder) { super(builder.getId()); - this.boardingArea = builder.getBoardingArea(); - this.alightingArea = builder.getAlightingArea(); - this.startTime = builder.getStartTime(); - this.endTime = builder.getEndTime(); - this.trip = builder.getTrip(); - this.provider = builder.getProvider(); - this.availableSeats = builder.getAvailableSeats(); + this.boardingArea = builder.boardingArea(); + this.alightingArea = builder.alightingArea(); + this.startTime = builder.startTime(); + this.endTime = builder.endTime(); + this.provider = builder.provider(); + this.availableSeats = builder.availableSeats(); + this.deviationBudget = builder.deviationBudget(); } - public AreaStop getBoardingArea() { + public AreaStop boardingArea() { return boardingArea; } - public AreaStop getAlightingArea() { + public AreaStop alightingArea() { return alightingArea; } - public ZonedDateTime getStartTime() { + public ZonedDateTime startTime() { return startTime; } - public ZonedDateTime getEndTime() { + public ZonedDateTime endTime() { return endTime; } - public Trip getTrip() { - return trip; + public String provider() { + return provider; } - public String getProvider() { - return provider; + public Duration deviationBudget() { + return deviationBudget; } - public int getAvailableSeats() { + public int availableSeats() { return availableSeats; } + public Duration tripDuration() { + // Since the endTime is set by the driver at creation, we subtract the deviationBudget to get the + // actual trip duration. + return Duration.between(startTime, endTime).minus(deviationBudget); + } + @Nullable @Override public String logName() { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java index 3b28a468a13..5aba6cd55e8 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java @@ -1,10 +1,10 @@ package org.opentripplanner.ext.carpooling.model; +import java.time.Duration; import java.time.ZonedDateTime; import org.opentripplanner.transit.model.framework.AbstractEntityBuilder; import org.opentripplanner.transit.model.framework.FeedScopedId; import org.opentripplanner.transit.model.site.AreaStop; -import org.opentripplanner.transit.model.timetable.Trip; public class CarpoolTripBuilder extends AbstractEntityBuilder { @@ -12,19 +12,20 @@ public class CarpoolTripBuilder extends AbstractEntityBuilder path = performCarpoolRouting( + tempVertices.getFromVertices(), + tempVertices.getToVertices() + ); + + if (path != null) { + // Get duration from the path + long durationSeconds = path.getDuration(); + return Duration.ofSeconds(durationSeconds); + } else { + LOG.debug("No route found between carpool stops, using straight-line estimate"); + return calculateDriveTimeFromDistance(boardingArea, alightingArea); + } + } catch (Exception e) { + LOG.error("Error calculating drive time with routing, falling back to distance estimate", e); + return calculateDriveTimeFromDistance(boardingArea, alightingArea); + } + } + + /** + * Performs A* street routing between two vertices using CAR mode. + * Returns the routing result with distance, time, and geometry. + */ + @Nullable + private GraphPath performCarpoolRouting( + Set from, + Set to + ) { + try { + // Create a basic route request for car routing + RouteRequest request = RouteRequest.defaultValue(); + + // Set up street request for CAR mode + StreetRequest streetRequest = new StreetRequest(StreetMode.CAR); + + float maxCarSpeed = streetLimitationParametersService.getMaxCarSpeed(); + + var streetSearch = StreetSearchBuilder.of() + .setHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) + .setSkipEdgeStrategy(new DurationSkipEdgeStrategy<>(MAX_ROUTE_DURATION)) + .setDominanceFunction(new DominanceFunctions.MinimumWeight()) + .setRequest(request) + .setStreetRequest(streetRequest) + .setFrom(from) + .setTo(to); + + List> paths = streetSearch.getPathsToTarget(); + + if (paths.isEmpty()) { + return null; + } + + paths.sort(new PathComparator(request.arriveBy())); + return paths.getFirst(); + } catch (Exception e) { + LOG.error("Error performing carpool routing", e); + return null; + } + } + + /** + * Calculate the estimated drive time based on straight-line distance. + * Used as a fallback when A* routing is not available. + * + * @param boardingArea the boarding area stop + * @param alightingArea the alighting area stop + * @return the estimated drive time as a Duration + */ + private Duration calculateDriveTimeFromDistance(AreaStop boardingArea, AreaStop alightingArea) { + double distanceInMeters = calculateDistance(boardingArea, alightingArea); + + // Add a buffer factor for traffic, stops, etc (30% additional time for straight-line) + double adjustedDistanceInMeters = distanceInMeters * 1.3; + + // Calculate time in seconds + double timeInSeconds = adjustedDistanceInMeters / DEFAULT_DRIVING_SPEED_MS; + + // Round up to nearest minute for more realistic estimates + long timeInMinutes = (long) Math.ceil(timeInSeconds / 60.0); + + return Duration.ofMinutes(timeInMinutes); + } + + /** + * Calculate the straight-line distance between two area stops using their centroids. + * + * @param boardingArea the boarding area stop + * @param alightingArea the alighting area stop + * @return the distance in meters + */ + private double calculateDistance(AreaStop boardingArea, AreaStop alightingArea) { + var boardingCoord = boardingArea.getCoordinate(); + var alightingCoord = alightingArea.getCoordinate(); + + // Convert WgsCoordinate to JTS Coordinate for SphericalDistanceLibrary + Coordinate from = new Coordinate(boardingCoord.longitude(), boardingCoord.latitude()); + Coordinate to = new Coordinate(alightingCoord.longitude(), alightingCoord.latitude()); + + return SphericalDistanceLibrary.distance(from, to); + } + private AreaStop buildAreaStop(EstimatedCall call, String id) { var stopAssignments = call.getDepartureStopAssignments(); if (stopAssignments == null || stopAssignments.size() != 1) { @@ -78,11 +256,10 @@ private AreaStop buildAreaStop(EstimatedCall call, String id) { private Polygon createPolygonFromGml(net.opengis.gml._3.PolygonType gmlPolygon) { var abstractRing = gmlPolygon.getExterior().getAbstractRing().getValue(); - if (!(abstractRing instanceof net.opengis.gml._3.LinearRingType)) { + if (!(abstractRing instanceof net.opengis.gml._3.LinearRingType linearRing)) { throw new IllegalArgumentException("Expected LinearRingType for polygon exterior"); } - var linearRing = (net.opengis.gml._3.LinearRingType) abstractRing; List values = linearRing.getPosList().getValue(); // Convert to JTS coordinates (lon lat pairs) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index cd4f7451ef5..f1dd84fc40e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -3,6 +3,9 @@ import java.util.List; import java.util.function.Consumer; import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.VertexLinker; +import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.updater.spi.PollingGraphUpdater; import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; import org.opentripplanner.updater.spi.UpdateResult; @@ -43,7 +46,10 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { public SiriETCarpoolingUpdater( SiriETCarpoolingUpdaterParameters config, - CarpoolingRepository repository + CarpoolingRepository repository, + Graph graph, + VertexLinker vertexLinker, + StreetLimitationParametersService streetLimitationParametersService ) { super(config); this.feedId = config.feedId(); @@ -59,7 +65,7 @@ public SiriETCarpoolingUpdater( this.metricsConsumer = TripUpdateMetrics.streaming(config); - this.mapper = new CarpoolSiriMapper(); + this.mapper = new CarpoolSiriMapper(graph, vertexLinker, streetLimitationParametersService); } /** @@ -72,12 +78,8 @@ public void runPolling() { do { var updates = updateSource.getUpdates(); if (updates.isPresent()) { - var incrementality = updateSource.incrementalityOfLastUpdates(); ServiceDelivery serviceDelivery = updates.get().getServiceDelivery(); moreData = Boolean.TRUE.equals(serviceDelivery.isMoreData()); - // Mark this updater as primed after last page of updates. Copy moreData into a final - // primitive, because the object moreData persists across iterations. - final boolean markPrimed = !moreData; List etds = serviceDelivery.getEstimatedTimetableDeliveries(); if (etds != null) { @@ -92,11 +94,12 @@ public void runPolling() { .getEstimatedVehicleJourneies() .forEach(ejv -> { var carpoolTrip = mapper.mapSiriToCarpoolTrip(ejv); - repository.addCarpoolTrip(carpoolTrip); + if (carpoolTrip != null) { + repository.addCarpoolTrip(carpoolTrip); + } }); } } - LOG.info("Received {} estimated timetable deliveries", etds.size()); } } } while (moreData); diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java index 3114fc180cb..5bffd39a129 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationFactory.java @@ -91,7 +91,6 @@ WorldEnvelopeServiceModule.class, } ) - public interface ConstructApplicationFactory { ConfigModel config(); RaptorConfig raptorConfig(); diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index 208346e67ab..e6fd2de7004 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -17,6 +17,8 @@ import org.opentripplanner.service.realtimevehicles.RealtimeVehicleRepository; import org.opentripplanner.service.vehicleparking.VehicleParkingRepository; import org.opentripplanner.service.vehiclerental.VehicleRentalRepository; +import org.opentripplanner.street.model.StreetLimitationParameters; +import org.opentripplanner.street.service.DefaultStreetLimitationParametersService; import org.opentripplanner.transit.service.TimetableRepository; import org.opentripplanner.updater.DefaultRealTimeUpdateContext; import org.opentripplanner.updater.GraphUpdaterManager; @@ -193,7 +195,19 @@ private List createUpdatersFromConfig() { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); } for (var configItem : updatersParameters.getSiriETCarpoolingUpdaterParameters()) { - updaters.add(new SiriETCarpoolingUpdater(configItem, carpoolingRepository)); + // Create a default street limitation parameters service for the updater + var streetLimitationParametersService = new DefaultStreetLimitationParametersService( + new StreetLimitationParameters() + ); + updaters.add( + new SiriETCarpoolingUpdater( + configItem, + carpoolingRepository, + graph, + linker, + streetLimitationParametersService + ) + ); } for (var configItem : updatersParameters.getSiriETLiteUpdaterParameters()) { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); From bbde8b0f9377e18e1d73ef4474fb6a0d79e84bf5 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 10 Sep 2025 15:54:26 +0200 Subject: [PATCH 05/40] Run prettier. --- .../internal/CarpoolItineraryMapper.java | 5 +-- .../internal/DefaultCarpoolingService.java | 31 ++++++++----------- .../carpooling/updater/CarpoolSiriMapper.java | 16 +++------- .../transit/speed_test/SpeedTest.java | 4 ++- 4 files changed, 21 insertions(+), 35 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java index cf48b35f9e1..8c4611e6fc3 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -27,10 +27,7 @@ public static Itinerary mapViaRouteToItinerary( candidate.pickupRoute().states.getLast().getTime() ); - var driverPickupTime = candidate - .trip() - .startTime() - .plus(pickupDuration); + var driverPickupTime = candidate.trip().startTime().plus(pickupDuration); // Main carpool leg (passenger origin to destination via shared route) // Start time is max of request dateTime and driverPickupTime diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java index fb5a5d62f84..7fe2ca11ad1 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java @@ -130,10 +130,7 @@ public List route(RouteRequest request) throws RoutingValidationExcep List viableCandidates = new ArrayList<>(); for (CarpoolTrip trip : availableTrips) { - ViaCarpoolCandidate candidate = evaluateViaRouteForTrip( - request, - trip - ); + ViaCarpoolCandidate candidate = evaluateViaRouteForTrip(request, trip); if (candidate != null) { viableCandidates.add(candidate); } @@ -161,16 +158,13 @@ public List route(RouteRequest request) throws RoutingValidationExcep * Evaluate a single carpool trip using the via-search algorithm. * Returns a viable candidate if the route A→C→D→B stays within the deviationBudget. */ - private ViaCarpoolCandidate evaluateViaRouteForTrip( - RouteRequest request, - CarpoolTrip trip - ) { - + private ViaCarpoolCandidate evaluateViaRouteForTrip(RouteRequest request, CarpoolTrip trip) { TemporaryVerticesContainer acTempVertices; try { acTempVertices = new TemporaryVerticesContainer( graph, vertexLinker, + null, GenericLocation.fromCoordinate(trip.boardingArea().getLat(), trip.boardingArea().getLon()), GenericLocation.fromCoordinate(request.from().lat, request.from().lng), StreetMode.CAR, // We'll route by car for all segments @@ -185,6 +179,7 @@ private ViaCarpoolCandidate evaluateViaRouteForTrip( cdTempVertices = new TemporaryVerticesContainer( graph, vertexLinker, + null, GenericLocation.fromCoordinate(request.from().lat, request.from().lng), GenericLocation.fromCoordinate(request.to().lat, request.to().lng), StreetMode.CAR, // We'll route by car for all segments @@ -199,8 +194,12 @@ private ViaCarpoolCandidate evaluateViaRouteForTrip( dbTempVertices = new TemporaryVerticesContainer( graph, vertexLinker, + null, GenericLocation.fromCoordinate(request.to().lat, request.to().lng), - GenericLocation.fromCoordinate(trip.alightingArea().getLat(), trip.alightingArea().getLon()), + GenericLocation.fromCoordinate( + trip.alightingArea().getLat(), + trip.alightingArea().getLon() + ), StreetMode.CAR, // We'll route by car for all segments StreetMode.CAR ); @@ -230,10 +229,9 @@ private ViaCarpoolCandidate evaluateViaRouteForTrip( } // Calculate total travel times - var viaDuration = - routeDuration(pickupRoute) - .plus(routeDuration(sharedRoute)) - .plus(routeDuration(dropoffRoute)); + var viaDuration = routeDuration(pickupRoute) + .plus(routeDuration(sharedRoute)) + .plus(routeDuration(dropoffRoute)); // Check if within deviation budget var deviationDuration = viaDuration.minus(trip.tripDuration()); @@ -276,10 +274,7 @@ private Duration routeDuration(GraphPath route) { if (route == null || route.states.isEmpty()) { return Duration.ZERO; } - return Duration.between( - route.states.getFirst().getTime(), - route.states.getLast().getTime() - ); + return Duration.between(route.states.getFirst().getTime(), route.states.getLast().getTime()); } /** diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index ac8d822658f..a5168f13481 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -90,10 +90,7 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { var scheduledDuration = Duration.between(startTime, endTime); // Calculate estimated drive time between stops for deviation budget - var estimatedDriveTime = calculateDriveTimeWithRouting( - boardingArea, - alightingArea - ); + var estimatedDriveTime = calculateDriveTimeWithRouting(boardingArea, alightingArea); var deviationBudget = scheduledDuration.minus(estimatedDriveTime); @@ -118,14 +115,12 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { * @param alightingArea the alighting area stop * @return the estimated drive time as a Duration */ - private Duration calculateDriveTimeWithRouting( - AreaStop boardingArea, - AreaStop alightingArea - ) { + private Duration calculateDriveTimeWithRouting(AreaStop boardingArea, AreaStop alightingArea) { try { var tempVertices = new TemporaryVerticesContainer( graph, vertexLinker, + null, GenericLocation.fromCoordinate(boardingArea.getLat(), boardingArea.getLon()), GenericLocation.fromCoordinate(alightingArea.getLat(), alightingArea.getLon()), StreetMode.CAR, @@ -157,10 +152,7 @@ private Duration calculateDriveTimeWithRouting( * Returns the routing result with distance, time, and geometry. */ @Nullable - private GraphPath performCarpoolRouting( - Set from, - Set to - ) { + private GraphPath performCarpoolRouting(Set from, Set to) { try { // Create a basic route request for car routing RouteRequest request = RouteRequest.defaultValue(); diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index aa02cf67410..e85979991e5 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -14,7 +14,7 @@ import java.util.function.Predicate; import org.opentripplanner.TestServerContext; import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository; -import org.opentripplanner.ext.fares.impl.DefaultFareService; +import org.opentripplanner.ext.fares.impl.gtfs.DefaultFareService; import org.opentripplanner.framework.application.OtpAppException; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.raptor.configure.RaptorConfig; @@ -142,6 +142,8 @@ public SpeedTest( null, null, null, + null, + null, null ); // Creating raptor transit data should be integrated into the TimetableRepository, but for now From eeeac6de1fdf5af4abbe9bd67bdc10692177c9c2 Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 12 Sep 2025 11:01:32 +0200 Subject: [PATCH 06/40] Fixes null pointer exception. --- .../ext/carpooling/CarpoolingRepository.java | 10 +- .../configure/CarpoolingModule.java | 4 +- .../data/KristiansandCarpoolingData.java | 174 ---------- .../internal/DefaultCarpoolingRepository.java | 80 +---- .../internal/DefaultCarpoolingService.java | 6 +- .../ext/carpooling/model/CarpoolStop.java | 303 ++++++++++++++++++ .../ext/carpooling/model/CarpoolTrip.java | 22 +- .../carpooling/model/CarpoolTripBuilder.java | 44 +++ .../carpooling/updater/CarpoolSiriMapper.java | 137 +++++++- .../updater/SiriETCarpoolingUpdater.java | 10 +- .../transit/speed_test/SpeedTest.java | 2 +- 11 files changed, 522 insertions(+), 270 deletions(-) delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java index 0e4672e7191..1782954db56 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java @@ -1,20 +1,12 @@ package org.opentripplanner.ext.carpooling; -import com.google.common.collect.ArrayListMultimap; import java.util.Collection; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.street.model.vertex.StreetVertex; -import org.opentripplanner.transit.model.site.AreaStop; /** * The CarpoolingRepository interface allows for the management and retrieval of carpooling trips. */ public interface CarpoolingRepository { Collection getCarpoolTrips(); - void addCarpoolTrip(CarpoolTrip trip); - CarpoolTrip getCarpoolTripByBoardingArea(AreaStop stop); - CarpoolTrip getCarpoolTripByAlightingArea(AreaStop stop); - - ArrayListMultimap getBoardingAreasForVertex(); - ArrayListMultimap getAlightingAreasForVertex(); + void upsertCarpoolTrip(CarpoolTrip trip); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index acedaa2079b..b698cc92951 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -18,11 +18,11 @@ public class CarpoolingModule { @Provides @Singleton - public CarpoolingRepository provideCarpoolingRepository(Graph graph) { + public CarpoolingRepository provideCarpoolingRepository() { if (OTPFeature.CarPooling.isOff()) { return null; } - return new DefaultCarpoolingRepository(graph); + return new DefaultCarpoolingRepository(); } @Provides diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java deleted file mode 100644 index 90d6694ea3c..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/data/KristiansandCarpoolingData.java +++ /dev/null @@ -1,174 +0,0 @@ -package org.opentripplanner.ext.carpooling.data; - -import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.ImmutableMultimap; -import com.google.common.collect.ListMultimap; -import com.google.common.collect.Multimap; -import java.lang.reflect.Array; -import java.time.LocalTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.Point; -import org.locationtech.jts.geom.Polygon; -import org.opentripplanner.ext.carpooling.CarpoolingRepository; -import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder; -import org.opentripplanner.ext.flex.AreaStopsToVerticesMapper; -import org.opentripplanner.framework.geometry.GeometryUtils; -import org.opentripplanner.framework.geometry.WgsCoordinate; -import org.opentripplanner.routing.alertpatch.EntityKey; -import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.street.model.vertex.StreetVertex; -import org.opentripplanner.transit.model.basic.TransitMode; -import org.opentripplanner.transit.model.framework.FeedScopedId; -import org.opentripplanner.transit.model.network.Route; -import org.opentripplanner.transit.model.organization.Operator; -import org.opentripplanner.transit.model.site.AreaStop; -import org.opentripplanner.transit.model.timetable.Trip; - -/** - * Utility class to create realistic carpooling trip data for the Kristiansand area. - * - * This creates carpooling trips connecting popular areas around Kristiansand: - * - Kvadraturen (downtown) - * - Lund (university area) - * - Gimle (residential area) - * - Sørlandsparken (shopping center) - * - Varoddbrua (bridge area) - * - Torridal (industrial area) - */ -public class KristiansandCarpoolingData { - - private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); - private static final ZoneId KRISTIANSAND_TIMEZONE = ZoneId.of("Europe/Oslo"); - private static final AtomicInteger COUNTER = new AtomicInteger(0); - - /** - * Populates the repository with realistic Kristiansand carpooling trips - */ - public static void populateRepository(CarpoolingRepository repository, Graph graph) { - // Create area stops for popular Kristiansand locations - AreaStop kvadraturenPickup = createAreaStop("kvadraturen-pickup", 58.1458, 7.9959, 300); // Downtown center - AreaStop lundDropoff = createAreaStop("lund-dropoff", 58.1665, 8.0041, 400); // University of Agder area - - AreaStop gimlePickup = createAreaStop("gimle-pickup", 58.1547, 7.9899, 250); // Gimle residential - AreaStop sorlandparkenDropoff = createAreaStop("sorlandparken-dropoff", 58.0969, 7.9786, 500); // Shopping center - - AreaStop varoddbruaPickup = createAreaStop("varoddbrua-pickup", 58.1389, 8.0180, 200); // Bridge area - AreaStop torridalDropoff = createAreaStop("torridal-dropoff", 58.1244, 8.0500, 350); // Industrial area - - AreaStop lundPickup = createAreaStop("lund-pickup", 58.1665, 8.0041, 400); // University pickup - AreaStop kvadraturenDropoff = createAreaStop("kvadraturen-dropoff", 58.1458, 7.9959, 300); // Downtown dropoff - - // Create carpooling trips for typical commuting patterns - - // Morning commute: Downtown to University (07:30-08:00) - repository.addCarpoolTrip( - createCarpoolTrip( - "morning-downtown-to-uni", - kvadraturenPickup, - lundDropoff, - LocalTime.of(7, 30), - LocalTime.of(8, 0), - "KristiansandRides", - 3 - ) - ); - - // Morning commute: Gimle to Sørlandsparken (08:00-08:25) - repository.addCarpoolTrip( - createCarpoolTrip( - "morning-gimle-to-shopping", - gimlePickup, - sorlandparkenDropoff, - LocalTime.of(8, 0), - LocalTime.of(8, 25), - "ShareRideKRS", - 2 - ) - ); - - // Morning commute: Varoddbrua to Torridal (07:45-08:10) - repository.addCarpoolTrip( - createCarpoolTrip( - "morning-bridge-to-industrial", - varoddbruaPickup, - torridalDropoff, - LocalTime.of(7, 45), - LocalTime.of(8, 10), - "CommuteBuddy", - 4 - ) - ); - - // Evening commute: University back to Downtown (16:30-17:00) - repository.addCarpoolTrip( - createCarpoolTrip( - "evening-uni-to-downtown", - lundPickup, - kvadraturenDropoff, - LocalTime.of(16, 30), - LocalTime.of(17, 0), - "KristiansandRides", - 2 - ) - ); - } - - private static CarpoolTrip createCarpoolTrip( - String tripId, - AreaStop boardingArea, - AreaStop alightingArea, - LocalTime startTime, - LocalTime endTime, - String provider, - int availableSeats - ) { - ZonedDateTime now = ZonedDateTime.now(KRISTIANSAND_TIMEZONE); - ZonedDateTime startDateTime = now.with(startTime); - ZonedDateTime endDateTime = now.with(endTime); - - return new CarpoolTripBuilder(new FeedScopedId("CARPOOL", tripId)) - .withBoardingArea(boardingArea) - .withAlightingArea(alightingArea) - .withStartTime(startDateTime) - .withEndTime(endDateTime) - .withProvider(provider) - .withAvailableSeats(availableSeats) - .build(); - } - - private static AreaStop createAreaStop(String id, double lat, double lon, double radiusMeters) { - WgsCoordinate center = new WgsCoordinate(lat, lon); - Polygon geometry = createCircularPolygon(center, radiusMeters); - - return AreaStop.of(new FeedScopedId("CARPOOL", id), COUNTER::getAndIncrement) - .withGeometry(geometry) - .build(); - } - - private static Polygon createCircularPolygon(WgsCoordinate center, double radiusMeters) { - // Create approximate circle using degree offsets (simplified for Kristiansand latitude) - double latOffset = radiusMeters / 111000.0; // ~111km per degree latitude - double lonOffset = radiusMeters / (111000.0 * Math.cos(Math.toRadians(center.latitude()))); // Adjust for latitude - - Coordinate[] coordinates = new Coordinate[13]; // 12 points + closing point - for (int i = 0; i < 12; i++) { - double angle = (2 * Math.PI * i) / 12; - double lat = center.latitude() + (latOffset * Math.cos(angle)); - double lon = center.longitude() + (lonOffset * Math.sin(angle)); - coordinates[i] = new Coordinate(lon, lat); - } - coordinates[12] = coordinates[0]; // Close the polygon - - return GEOMETRY_FACTORY.createPolygon(coordinates); - } - - private static String formatAreaName(String id) { - return id.replace("-pickup", "").replace("-dropoff", "").replace("-", " ").toUpperCase(); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java index 361556de15e..1f7634100f4 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java @@ -1,18 +1,11 @@ package org.opentripplanner.ext.carpooling.internal; -import com.google.common.collect.ArrayListMultimap; import java.util.Collection; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.locationtech.jts.geom.Point; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.framework.geometry.GeometryUtils; -import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.street.model.vertex.StreetVertex; import org.opentripplanner.transit.model.framework.FeedScopedId; -import org.opentripplanner.transit.model.site.AreaStop; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,79 +13,20 @@ public class DefaultCarpoolingRepository implements CarpoolingRepository { private static final Logger LOG = LoggerFactory.getLogger(DefaultCarpoolingRepository.class); - private final Graph graph; - private final Map trips = new ConcurrentHashMap<>(); - private final Map boardingAreas = new ConcurrentHashMap<>(); - private final Map alightingAreas = new ConcurrentHashMap<>(); - - private final ArrayListMultimap boardingAreasByVertex = - ArrayListMultimap.create(); - private final ArrayListMultimap alightingAreasByVertex = - ArrayListMultimap.create(); - - public DefaultCarpoolingRepository(Graph graph) { - this.graph = graph; - } - @Override public Collection getCarpoolTrips() { return trips.values(); } @Override - public void addCarpoolTrip(CarpoolTrip trip) { - trips.put(trip.getId(), trip); - - var boardingArea = trip.boardingArea(); - var alightingArea = trip.alightingArea(); - - boardingAreas.put(boardingArea, trip); - alightingAreas.put(alightingArea, trip); - - streetVerticesWithinAreaStop(boardingArea).forEach(v -> { - boardingAreasByVertex.put(v, boardingArea); - }); - - streetVerticesWithinAreaStop(alightingArea).forEach(v -> { - alightingAreasByVertex.put(v, alightingArea); - }); - LOG.info("Added carpooling trip for start time: {}", trip.startTime()); - } - - private List streetVerticesWithinAreaStop(AreaStop stop) { - return graph - .findVertices(stop.getGeometry().getEnvelopeInternal()) - .stream() - .filter(StreetVertex.class::isInstance) - .map(StreetVertex.class::cast) - .filter(StreetVertex::isEligibleForCarPickupDropoff) - .filter(vertx -> { - // The street index overselects, so need to check for exact geometry inclusion - Point p = GeometryUtils.getGeometryFactory().createPoint(vertx.getCoordinate()); - return stop.getGeometry().intersects(p); - }) - .toList(); - } - - @Override - public CarpoolTrip getCarpoolTripByBoardingArea(AreaStop boardingArea) { - return boardingAreas.get(boardingArea); - } - - @Override - public CarpoolTrip getCarpoolTripByAlightingArea(AreaStop alightingArea) { - return alightingAreas.get(alightingArea); - } - - @Override - public ArrayListMultimap getBoardingAreasForVertex() { - return boardingAreasByVertex; - } - - @Override - public ArrayListMultimap getAlightingAreasForVertex() { - return alightingAreasByVertex; + public void upsertCarpoolTrip(CarpoolTrip trip) { + CarpoolTrip existingTrip = trips.put(trip.getId(), trip); + if (existingTrip != null) { + LOG.info("Updated carpool trip {} with {} stops", trip.getId(), trip.stops().size()); + } else { + LOG.info("Added new carpool trip {} with {} stops", trip.getId(), trip.stops().size()); + } } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java index 7fe2ca11ad1..46cb3a7293b 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java @@ -13,7 +13,6 @@ import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; -import org.opentripplanner.ext.carpooling.data.KristiansandCarpoolingData; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.model.GenericLocation; import org.opentripplanner.model.plan.Itinerary; @@ -52,7 +51,6 @@ public DefaultCarpoolingService( Graph graph, VertexLinker vertexLinker ) { - KristiansandCarpoolingData.populateRepository(repository, graph); this.streetLimitationParametersService = streetLimitationParametersService; this.repository = repository; this.graph = graph; @@ -305,6 +303,10 @@ private GraphPath carpoolRouting( List> paths = streetSearch.getPathsToTarget(); paths.sort(new PathComparator(request.arriveBy())); + if (paths.isEmpty()) { + return null; + } + return paths.getFirst(); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java new file mode 100644 index 00000000000..4da58fe0d1d --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java @@ -0,0 +1,303 @@ +package org.opentripplanner.ext.carpooling.model; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.List; +import javax.annotation.Nullable; +import org.locationtech.jts.geom.Geometry; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.transit.model.basic.Accessibility; +import org.opentripplanner.transit.model.basic.SubMode; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.site.AreaStop; +import org.opentripplanner.transit.model.site.FareZone; +import org.opentripplanner.transit.model.site.Station; +import org.opentripplanner.transit.model.site.StopLocation; +import org.opentripplanner.transit.model.site.StopTransferPriority; +import org.opentripplanner.transit.model.site.StopType; + +/** + * Represents a stop along a carpool trip route with passenger pickup/drop-off information. + * Each stop tracks the passenger delta (number of passengers picked up or dropped off). + * Stops are ordered sequentially along the route. + */ +public class CarpoolStop implements StopLocation { + + /** + * The type of carpool stop operation + */ + public enum CarpoolStopType { + /** Only passengers can be picked up at this stop */ + PICKUP_ONLY, + /** Only passengers can be dropped off at this stop */ + DROP_OFF_ONLY, + /** Both pickup and drop-off are allowed */ + PICKUP_AND_DROP_OFF, + } + + private final AreaStop areaStop; + private final CarpoolStopType carpoolStopType; + private final int passengerDelta; + private final int sequenceNumber; + private final ZonedDateTime estimatedTime; + + /** + * Creates a new CarpoolStop + * + * @param areaStop The area stop where passengers can board/alight + * @param carpoolStopType The type of operation allowed at this stop + * @param passengerDelta Number of passengers picked up (positive) or dropped off (negative) + * @param sequenceNumber The order of this stop in the trip (0-based) + * @param estimatedTime The estimated arrival/departure time at this stop + */ + public CarpoolStop( + AreaStop areaStop, + CarpoolStopType carpoolStopType, + int passengerDelta, + int sequenceNumber, + @Nullable ZonedDateTime estimatedTime + ) { + this.areaStop = areaStop; + this.carpoolStopType = carpoolStopType; + this.passengerDelta = passengerDelta; + this.sequenceNumber = sequenceNumber; + this.estimatedTime = estimatedTime; + } + + // StopLocation interface implementation - delegate to the underlying AreaStop + + @Override + public FeedScopedId getId() { + return areaStop.getId(); + } + + @Override + public int getIndex() { + return areaStop.getIndex(); + } + + @Override + @Nullable + public I18NString getName() { + return areaStop.getName(); + } + + @Override + @Nullable + public I18NString getDescription() { + return areaStop.getDescription(); + } + + @Override + @Nullable + public I18NString getUrl() { + return areaStop.getUrl(); + } + + @Override + public StopType getStopType() { + return areaStop.getStopType(); + } + + @Override + @Nullable + public String getCode() { + return areaStop.getCode(); + } + + @Override + @Nullable + public String getPlatformCode() { + return areaStop.getPlatformCode(); + } + + @Override + @Nullable + public TransitMode getVehicleType() { + return areaStop.getVehicleType(); + } + + @Override + public SubMode getNetexVehicleSubmode() { + return areaStop.getNetexVehicleSubmode(); + } + + @Override + @Nullable + public Station getParentStation() { + return areaStop.getParentStation(); + } + + @Override + public Collection getFareZones() { + return areaStop.getFareZones(); + } + + @Override + public Accessibility getWheelchairAccessibility() { + return areaStop.getWheelchairAccessibility(); + } + + @Override + public WgsCoordinate getCoordinate() { + return areaStop.getCoordinate(); + } + + @Override + @Nullable + public Geometry getGeometry() { + return areaStop.getGeometry(); + } + + @Override + @Nullable + public ZoneId getTimeZone() { + return areaStop.getTimeZone(); + } + + @Override + public boolean isPartOfStation() { + return areaStop.isPartOfStation(); + } + + @Override + public StopTransferPriority getPriority() { + return areaStop.getPriority(); + } + + @Override + public boolean isPartOfSameStationAs(StopLocation alternativeStop) { + return areaStop.isPartOfSameStationAs(alternativeStop); + } + + @Override + @Nullable + public List getChildLocations() { + return areaStop.getChildLocations(); + } + + @Override + public boolean transfersNotAllowed() { + return areaStop.transfersNotAllowed(); + } + + // Carpool-specific methods + + /** + * @return The underlying regular stop (point-based) + */ + public AreaStop getAreaStop() { + return areaStop; + } + + /** + * @return The type of carpool stop operation allowed + */ + public CarpoolStopType getCarpoolStopType() { + return carpoolStopType; + } + + /** + * @return The passenger delta at this stop. Positive values indicate pickups, + * negative values indicate drop-offs + */ + public int getPassengerDelta() { + return passengerDelta; + } + + /** + * @return The sequence number of this stop in the trip (0-based) + */ + public int getSequenceNumber() { + return sequenceNumber; + } + + /** + * @return The estimated time at this stop, null if not available + */ + @Nullable + public ZonedDateTime getEstimatedTime() { + return estimatedTime; + } + + /** + * @return true if this stop allows passenger pickups + */ + public boolean allowsPickup() { + return ( + carpoolStopType == CarpoolStopType.PICKUP_ONLY || + carpoolStopType == CarpoolStopType.PICKUP_AND_DROP_OFF + ); + } + + /** + * @return true if this stop allows passenger drop-offs + */ + public boolean allowsDropOff() { + return ( + carpoolStopType == CarpoolStopType.DROP_OFF_ONLY || + carpoolStopType == CarpoolStopType.PICKUP_AND_DROP_OFF + ); + } + + /** + * @return true if passengers are being picked up at this stop (positive delta) + */ + public boolean isPickupStop() { + return passengerDelta > 0; + } + + /** + * @return true if passengers are being dropped off at this stop (negative delta) + */ + public boolean isDropOffStop() { + return passengerDelta < 0; + } + + /** + * @return the absolute number of passengers affected at this stop + */ + public int getPassengerCount() { + return Math.abs(passengerDelta); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof CarpoolStop other)) return false; + + return ( + areaStop.equals(other.areaStop) && + carpoolStopType == other.carpoolStopType && + passengerDelta == other.passengerDelta && + sequenceNumber == other.sequenceNumber && + java.util.Objects.equals(estimatedTime, other.estimatedTime) + ); + } + + @Override + public int hashCode() { + return java.util.Objects.hash( + areaStop, + carpoolStopType, + passengerDelta, + sequenceNumber, + estimatedTime + ); + } + + @Override + public String toString() { + return String.format( + "CarpoolStop{stop=%s, type=%s, delta=%d, seq=%d, time=%s}", + areaStop.getId(), + carpoolStopType, + passengerDelta, + sequenceNumber, + estimatedTime + ); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 4b2c966bd9a..443efc220d4 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -2,6 +2,8 @@ import java.time.Duration; import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.List; import javax.annotation.Nullable; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.framework.LogInfo; @@ -9,9 +11,9 @@ import org.opentripplanner.transit.model.site.AreaStop; /** - * A carpool trip is defined by two area stops and a start time, in addition to all the other fields - * that are necessary for a valid Trip. It is created from SIRI ET messages that contain the - * necessary identifiers and trip information. + * A carpool trip is defined by boarding and alighting areas, a start time, and a sequence of stops + * where passengers can be picked up or dropped off. + * It is created from SIRI ET messages that contain the necessary identifiers and trip information. */ public class CarpoolTrip extends AbstractTransitEntity @@ -28,6 +30,9 @@ public class CarpoolTrip private final Duration deviationBudget; private final int availableSeats; + // Ordered list of stops along the carpool route where passengers can be picked up or dropped off + private final List stops; + public CarpoolTrip(CarpoolTripBuilder builder) { super(builder.getId()); this.boardingArea = builder.boardingArea(); @@ -37,6 +42,7 @@ public CarpoolTrip(CarpoolTripBuilder builder) { this.provider = builder.provider(); this.availableSeats = builder.availableSeats(); this.deviationBudget = builder.deviationBudget(); + this.stops = Collections.unmodifiableList(builder.stops()); } public AreaStop boardingArea() { @@ -67,6 +73,13 @@ public int availableSeats() { return availableSeats; } + /** + * @return An immutable list of stops along the carpool route, ordered by sequence + */ + public List stops() { + return stops; + } + public Duration tripDuration() { // Since the endTime is set by the driver at creation, we subtract the deviationBudget to get the // actual trip duration. @@ -86,7 +99,8 @@ public boolean sameAs(CarpoolTrip other) { boardingArea.equals(other.boardingArea) && alightingArea.equals(other.alightingArea) && startTime.equals(other.startTime) && - endTime.equals(other.endTime) + endTime.equals(other.endTime) && + stops.equals(other.stops) ); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java index 5aba6cd55e8..974d606163e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java @@ -2,6 +2,8 @@ import java.time.Duration; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; import org.opentripplanner.transit.model.framework.AbstractEntityBuilder; import org.opentripplanner.transit.model.framework.FeedScopedId; import org.opentripplanner.transit.model.site.AreaStop; @@ -16,6 +18,7 @@ public class CarpoolTripBuilder extends AbstractEntityBuilder stops = new ArrayList<>(); public CarpoolTripBuilder(CarpoolTrip original) { super(original); @@ -26,6 +29,7 @@ public CarpoolTripBuilder(CarpoolTrip original) { this.provider = original.provider(); this.deviationBudget = original.deviationBudget(); this.availableSeats = original.availableSeats(); + this.stops = new ArrayList<>(original.stops()); } public CarpoolTripBuilder(FeedScopedId id) { @@ -95,8 +99,48 @@ public int availableSeats() { return availableSeats; } + public CarpoolTripBuilder withStops(List stops) { + this.stops = new ArrayList<>(stops); + return this; + } + + public CarpoolTripBuilder addStop(CarpoolStop stop) { + this.stops.add(stop); + // Sort stops by sequence number to maintain order + this.stops.sort((a, b) -> Integer.compare(a.getSequenceNumber(), b.getSequenceNumber())); + return this; + } + + public CarpoolTripBuilder clearStops() { + this.stops.clear(); + return this; + } + + public List stops() { + return stops; + } + @Override protected CarpoolTrip buildFromValues() { + // Validate stops are properly ordered by sequence number + validateStopSequence(); + return new CarpoolTrip(this); } + + private void validateStopSequence() { + for (int i = 0; i < stops.size(); i++) { + CarpoolStop stop = stops.get(i); + if (stop.getSequenceNumber() != i) { + throw new IllegalStateException( + String.format( + "Stop sequence mismatch: expected %d but got %d at position %d", + i, + stop.getSequenceNumber(), + i + ) + ); + } + } + } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index a5168f13481..de47ae02215 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -13,6 +14,7 @@ import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; import org.opentripplanner.astar.strategy.PathComparator; +import org.opentripplanner.ext.carpooling.model.CarpoolStop; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder; import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; @@ -65,8 +67,10 @@ public CarpoolSiriMapper( public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { var calls = journey.getEstimatedCalls().getEstimatedCalls(); - if (calls.size() != 2) { - throw new IllegalArgumentException("Carpool trips must have exactly 2 stops for now."); + if (calls.size() < 2) { + throw new IllegalArgumentException( + "Carpool trips must have at least 2 stops (boarding and alighting)." + ); } var boardingCall = calls.getFirst(); @@ -96,6 +100,17 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { String provider = journey.getOperatorRef().getValue(); + // Validate EstimatedCall timing order before processing + validateEstimatedCallOrder(calls); + + // Build intermediate stops from EstimatedCalls (excluding first and last) + List stops = new ArrayList<>(); + for (int i = 1; i < calls.size() - 1; i++) { + EstimatedCall intermediateCall = calls.get(i); + CarpoolStop stop = buildCarpoolStop(intermediateCall, tripId, i - 1); // 0-based sequence + stops.add(stop); + } + return new CarpoolTripBuilder(new FeedScopedId(FEED_ID, tripId)) .withBoardingArea(boardingArea) .withAlightingArea(alightingArea) @@ -104,6 +119,7 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { .withProvider(provider) .withDeviationBudget(deviationBudget) .withAvailableSeats(1) // Default value, could be enhanced if data available + .withStops(stops) .build(); } @@ -226,6 +242,123 @@ private double calculateDistance(AreaStop boardingArea, AreaStop alightingArea) return SphericalDistanceLibrary.distance(from, to); } + /** + * Build a CarpoolStop from an EstimatedCall, using point geometry instead of area geometry. + * Determines the stop type and passenger delta from the call data. + * + * @param call The SIRI EstimatedCall containing stop information + * @param tripId The trip ID for generating unique stop IDs + * @param sequenceNumber The 0-based sequence number of this stop + * @return A CarpoolStop representing the intermediate pickup/drop-off point + */ + private CarpoolStop buildCarpoolStop(EstimatedCall call, String tripId, int sequenceNumber) { + var areaStop = buildAreaStop(call, tripId + "_stop_" + sequenceNumber); + + // Extract timing information + ZonedDateTime estimatedTime = call.getExpectedArrivalTime() != null + ? call.getExpectedArrivalTime() + : call.getAimedArrivalTime(); + + // Determine stop type and passenger delta from call attributes + // In SIRI ET, passenger changes are typically indicated by boarding/alighting counts + CarpoolStop.CarpoolStopType stopType = determineCarpoolStopType(call); + int passengerDelta = calculatePassengerDelta(call, stopType); + + return new CarpoolStop(areaStop, stopType, passengerDelta, sequenceNumber, estimatedTime); + } + + /** + * Determine the carpool stop type from the EstimatedCall data. + */ + private CarpoolStop.CarpoolStopType determineCarpoolStopType(EstimatedCall call) { + // This is a simplified implementation - adapt based on your SIRI ET data structure + // You might have specific fields indicating whether this is pickup, drop-off, or both + + boolean hasArrival = + call.getExpectedArrivalTime() != null || call.getAimedArrivalTime() != null; + boolean hasDeparture = + call.getExpectedDepartureTime() != null || call.getAimedDepartureTime() != null; + + if (hasArrival && hasDeparture) { + return CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF; + } else if (hasDeparture) { + return CarpoolStop.CarpoolStopType.PICKUP_ONLY; + } else if (hasArrival) { + return CarpoolStop.CarpoolStopType.DROP_OFF_ONLY; + } else { + // Default fallback + return CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF; + } + } + + /** + * Calculate the passenger delta (change in passenger count) from the EstimatedCall. + */ + private int calculatePassengerDelta(EstimatedCall call, CarpoolStop.CarpoolStopType stopType) { + // This is a placeholder implementation - adapt based on SIRI ET data structure + // SIRI ET may have passenger count changes, boarding/alighting numbers, etc. + + // For now, return a default value of 1 passenger pickup/dropoff + if (stopType == CarpoolStop.CarpoolStopType.DROP_OFF_ONLY) { + return -1; // Assume 1 passenger drop-off + } else if (stopType == CarpoolStop.CarpoolStopType.PICKUP_ONLY) { + return 1; // Assume 1 passenger pickup + } else { + return 0; // No net change for both pickup and drop-off + } + } + + /** + * Validates that the EstimatedCalls are properly ordered in time. + * Ensures intermediate stops occur between the first (boarding) and last (alighting) calls. + */ + private void validateEstimatedCallOrder(List calls) { + if (calls.size() < 2) { + return; // No validation needed for fewer than 2 calls + } + + ZonedDateTime firstTime = calls.getFirst().getAimedDepartureTime(); // Use departure time for first call + ZonedDateTime lastTime = calls.getLast().getAimedArrivalTime(); // Use arrival time for last call + + if (firstTime == null || lastTime == null) { + LOG.warn("Cannot validate call order - missing timing information in first or last call"); + return; + } + + if (firstTime.isAfter(lastTime)) { + throw new IllegalArgumentException( + String.format( + "Invalid call order: first call time (%s) is after last call time (%s)", + firstTime, + lastTime + ) + ); + } + + // Validate intermediate calls are between first and last + for (int i = 1; i < calls.size() - 1; i++) { + EstimatedCall intermediateCall = calls.get(i); + ZonedDateTime intermediateTime = intermediateCall.getAimedDepartureTime() != null ? intermediateCall.getAimedDepartureTime() : intermediateCall.getAimedArrivalTime(); + + if (intermediateTime == null) { + LOG.warn("Intermediate call at index {} has no timing information", i); + continue; + } + + if (intermediateTime.isBefore(firstTime) || intermediateTime.isAfter(lastTime)) { + throw new IllegalArgumentException( + String.format( + "Invalid call order: intermediate call at index %d (time: %s) is not between first (%s) and last (%s) calls", + i, + intermediateTime, + firstTime, + lastTime + ) + ); + } + } + } + private AreaStop buildAreaStop(EstimatedCall call, String id) { var stopAssignments = call.getDepartureStopAssignments(); if (stopAssignments == null || stopAssignments.size() != 1) { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index f1dd84fc40e..7f99587b02d 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -93,9 +93,13 @@ public void runPolling() { ejvf .getEstimatedVehicleJourneies() .forEach(ejv -> { - var carpoolTrip = mapper.mapSiriToCarpoolTrip(ejv); - if (carpoolTrip != null) { - repository.addCarpoolTrip(carpoolTrip); + try { + var carpoolTrip = mapper.mapSiriToCarpoolTrip(ejv); + if (carpoolTrip != null) { + repository.upsertCarpoolTrip(carpoolTrip); + } + } catch (Exception e) { + LOG.warn("Failed to process EstimatedVehicleJourney: {}", e.getMessage()); } }); } diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index e85979991e5..cd35f4250b7 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -102,7 +102,7 @@ public SpeedTest( new DefaultVehicleRentalService(), new DefaultVehicleParkingRepository(), timetableRepository, - new DefaultCarpoolingRepository(graph), + new DefaultCarpoolingRepository(), new TimetableSnapshotManager(null, TimetableSnapshotParameters.DEFAULT, LocalDate::now), config.updatersConfig ); From 5a20682c557c5b69bc07362516780790127e38cb Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 9 Oct 2025 07:36:26 +0200 Subject: [PATCH 07/40] New carpooling implementation. --- .../configure/CarpoolingModule.java | 11 +- .../ext/carpooling/filter/CapacityFilter.java | 32 ++ .../DirectionalCompatibilityFilter.java | 131 ++++++++ .../ext/carpooling/filter/FilterChain.java | 62 ++++ .../ext/carpooling/filter/TripFilter.java | 53 +++ .../internal/CarpoolItineraryMapper.java | 65 ++-- .../internal/DefaultCarpoolingService.java | 312 ------------------ .../internal/ViaCarpoolCandidate.java | 26 -- .../routing/InsertionCandidate.java | 83 +++++ .../routing/OptimalInsertionStrategy.java | 256 ++++++++++++++ .../ext/carpooling/routing/RoutePoint.java | 25 ++ .../service/DefaultCarpoolingService.java | 233 +++++++++++++ .../carpooling/updater/CarpoolSiriMapper.java | 20 +- .../util/DirectionalCalculator.java | 185 +++++++++++ .../util/PassengerCountTimeline.java | 141 ++++++++ .../ext/carpooling/util/RouteGeometry.java | 124 +++++++ .../validation/CapacityValidator.java | 52 +++ .../validation/CompositeValidator.java | 57 ++++ .../validation/DirectionalValidator.java | 107 ++++++ .../validation/InsertionValidator.java | 88 +++++ .../nearbystops/StreetNearbyStopFinder.java | 3 +- .../api/OtpServerRequestContext.java | 16 +- .../ext/carpooling/MockGraphPathFactory.java | 68 ++++ .../carpooling/TestCarpoolTripBuilder.java | 133 ++++++++ .../ext/carpooling/TestFixtures.java | 34 ++ .../carpooling/filter/CapacityFilterTest.java | 86 +++++ .../DirectionalCompatibilityFilterTest.java | 135 ++++++++ .../carpooling/filter/FilterChainTest.java | 121 +++++++ .../routing/InsertionCandidateTest.java | 229 +++++++++++++ .../routing/OptimalInsertionStrategyTest.java | 204 ++++++++++++ .../carpooling/routing/RoutePointTest.java | 79 +++++ .../util/DirectionalCalculatorTest.java | 225 +++++++++++++ .../util/PassengerCountTimelineTest.java | 133 ++++++++ .../carpooling/util/RouteGeometryTest.java | 99 ++++++ .../validation/CapacityValidatorTest.java | 120 +++++++ .../validation/CompositeValidatorTest.java | 157 +++++++++ .../validation/DirectionalValidatorTest.java | 181 ++++++++++ 37 files changed, 3694 insertions(+), 392 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/filter/CapacityFilter.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidate.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index b698cc92951..ea50dc3d975 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -6,11 +6,10 @@ import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository; -import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingService; +import org.opentripplanner.ext.carpooling.service.DefaultCarpoolingService; import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.routing.linking.VertexLinker; -import org.opentripplanner.standalone.api.OtpServerRequestContext; import org.opentripplanner.street.service.StreetLimitationParametersService; @Module @@ -27,19 +26,19 @@ public CarpoolingRepository provideCarpoolingRepository() { @Provides public static CarpoolingService provideCarpoolingService( - StreetLimitationParametersService streetLimitationParametersService, CarpoolingRepository repository, Graph graph, - VertexLinker vertexLinker + VertexLinker vertexLinker, + StreetLimitationParametersService streetLimitationParametersService ) { if (OTPFeature.CarPooling.isOff()) { return null; } return new DefaultCarpoolingService( - streetLimitationParametersService, repository, graph, - vertexLinker + vertexLinker, + streetLimitationParametersService ); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/CapacityFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/CapacityFilter.java new file mode 100644 index 00000000000..da2ed651d88 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/CapacityFilter.java @@ -0,0 +1,32 @@ +package org.opentripplanner.ext.carpooling.filter; + +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filters trips based on available capacity. + *

+ * This is a fast pre-filter that checks if the trip has any capacity at all. + * More detailed per-position capacity checking happens during insertion validation. + */ +public class CapacityFilter implements TripFilter { + + private static final Logger LOG = LoggerFactory.getLogger(CapacityFilter.class); + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + boolean hasCapacity = trip.availableSeats() > 0; + + if (!hasCapacity) { + LOG.debug("Trip {} rejected by capacity filter: no available seats", trip.getId()); + } + + return hasCapacity; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java new file mode 100644 index 00000000000..3f36b404651 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java @@ -0,0 +1,131 @@ +package org.opentripplanner.ext.carpooling.filter; + +import java.util.ArrayList; +import java.util.List; +import org.opentripplanner.ext.carpooling.model.CarpoolStop; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; +import org.opentripplanner.ext.carpooling.util.RouteGeometry; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filters trips based on directional compatibility with the passenger journey. + *

+ * This prevents carpooling from becoming a taxi service by ensuring trips and + * passengers are going in generally the same direction. Uses segment-based + * analysis to handle routes that take detours (e.g., driving around a lake). + */ +public class DirectionalCompatibilityFilter implements TripFilter { + + private static final Logger LOG = LoggerFactory.getLogger(DirectionalCompatibilityFilter.class); + + /** Default maximum bearing difference for compatibility */ + public static final double DEFAULT_BEARING_TOLERANCE_DEGREES = 60.0; + + private final double bearingToleranceDegrees; + + public DirectionalCompatibilityFilter() { + this(DEFAULT_BEARING_TOLERANCE_DEGREES); + } + + public DirectionalCompatibilityFilter(double bearingToleranceDegrees) { + this.bearingToleranceDegrees = bearingToleranceDegrees; + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + // Build route points list + List routePoints = buildRoutePoints(trip); + + // Check if passenger journey is compatible with any segment of the route + double passengerBearing = DirectionalCalculator.calculateBearing( + passengerPickup, + passengerDropoff + ); + + // Try all possible segment ranges + for (int startIdx = 0; startIdx < routePoints.size() - 1; startIdx++) { + for (int endIdx = startIdx + 1; endIdx < routePoints.size(); endIdx++) { + if ( + isSegmentRangeCompatible( + routePoints, + startIdx, + endIdx, + passengerBearing, + passengerPickup, + passengerDropoff + ) + ) { + LOG.debug( + "Trip {} accepted: passenger journey aligns with route segments {} to {}", + trip.getId(), + startIdx, + endIdx + ); + return true; + } + } + } + + LOG.debug( + "Trip {} rejected by directional filter: passenger journey (bearing {}°) not aligned with any route segments", + trip.getId(), + Math.round(passengerBearing) + ); + return false; + } + + /** + * Builds the list of route points (boarding → stops → alighting). + */ + private List buildRoutePoints(CarpoolTrip trip) { + List points = new ArrayList<>(); + + // Add boarding area + points.add(trip.boardingArea().getCoordinate()); + + // Add existing stops + for (CarpoolStop stop : trip.stops()) { + points.add(stop.getCoordinate()); + } + + // Add alighting area + points.add(trip.alightingArea().getCoordinate()); + + return points; + } + + /** + * Checks if a range of route segments is compatible with the passenger journey. + */ + private boolean isSegmentRangeCompatible( + List routePoints, + int startIdx, + int endIdx, + double passengerBearing, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + // Calculate overall bearing for this segment range + WgsCoordinate rangeStart = routePoints.get(startIdx); + WgsCoordinate rangeEnd = routePoints.get(endIdx); + double rangeBearing = DirectionalCalculator.calculateBearing(rangeStart, rangeEnd); + + // Check directional compatibility + double bearingDiff = DirectionalCalculator.bearingDifference(rangeBearing, passengerBearing); + + if (bearingDiff <= bearingToleranceDegrees) { + // Also verify that pickup/dropoff are within the route corridor + List segmentPoints = routePoints.subList(startIdx, endIdx + 1); + return RouteGeometry.areBothWithinCorridor(segmentPoints, passengerPickup, passengerDropoff); + } + + return false; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java new file mode 100644 index 00000000000..d5618da1f2d --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java @@ -0,0 +1,62 @@ +package org.opentripplanner.ext.carpooling.filter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Combines multiple trip filters using AND logic (all filters must pass). + *

+ * Filters are evaluated in order, with short-circuit evaluation: + * as soon as one filter rejects a trip, evaluation stops. + */ +public class FilterChain implements TripFilter { + + private final List filters; + + public FilterChain(List filters) { + this.filters = new ArrayList<>(filters); + } + + public FilterChain(TripFilter... filters) { + this(Arrays.asList(filters)); + } + + /** + * Creates a standard filter chain with capacity and directional filters. + */ + public static FilterChain standard() { + return new FilterChain(new CapacityFilter(), new DirectionalCompatibilityFilter()); + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + for (TripFilter filter : filters) { + if (!filter.accepts(trip, passengerPickup, passengerDropoff)) { + return false; // Short-circuit: filter rejected the trip + } + } + return true; // All filters passed + } + + /** + * Adds a filter to the chain. + */ + public FilterChain add(TripFilter filter) { + filters.add(filter); + return this; + } + + /** + * Gets the number of filters in the chain. + */ + public int size() { + return filters.size(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java new file mode 100644 index 00000000000..2b46676b7db --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java @@ -0,0 +1,53 @@ +package org.opentripplanner.ext.carpooling.filter; + +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Interface for filtering carpool trips before expensive routing calculations. + *

+ * Filters are applied as a pre-screening mechanism to quickly eliminate + * incompatible trips based on various criteria (direction, capacity, etc.). + */ +@FunctionalInterface +public interface TripFilter { + /** + * Checks if a trip passes this filter for the given passenger request. + * + * @param trip The carpool trip to evaluate + * @param passengerPickup Passenger's pickup location + * @param passengerDropoff Passenger's dropoff location + * @return true if the trip passes the filter, false otherwise + */ + boolean accepts(CarpoolTrip trip, WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff); + + /** + * Returns a filter that always accepts all trips. + */ + static TripFilter acceptAll() { + return (trip, pickup, dropoff) -> true; + } + + /** + * Returns a filter that combines this filter with another using AND logic. + */ + default TripFilter and(TripFilter other) { + return (trip, pickup, dropoff) -> + this.accepts(trip, pickup, dropoff) && other.accepts(trip, pickup, dropoff); + } + + /** + * Returns a filter that combines this filter with another using OR logic. + */ + default TripFilter or(TripFilter other) { + return (trip, pickup, dropoff) -> + this.accepts(trip, pickup, dropoff) || other.accepts(trip, pickup, dropoff); + } + + /** + * Returns a filter that negates this filter. + */ + default TripFilter negate() { + return (trip, pickup, dropoff) -> !this.accepts(trip, pickup, dropoff); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java index 8c4611e6fc3..8f329961762 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -4,6 +4,7 @@ import java.time.ZoneId; import java.util.List; import org.opentripplanner.ext.carpooling.model.CarpoolLeg; +import org.opentripplanner.ext.carpooling.routing.InsertionCandidate; import org.opentripplanner.framework.geometry.GeometryUtils; import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.framework.model.Cost; @@ -11,52 +12,66 @@ import org.opentripplanner.model.plan.Place; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; public class CarpoolItineraryMapper { /** - * Creates an itinerary from a viable via-route carpool candidate. - * This uses the shared route segment as the main carpool leg. + * Creates an itinerary from an insertion candidate (refactored version). + * Works with the new InsertionCandidate type from the refactored routing system. */ - public static Itinerary mapViaRouteToItinerary( - RouteRequest request, - ViaCarpoolCandidate candidate - ) { - var pickupDuration = Duration.between( - candidate.pickupRoute().states.getFirst().getTime(), - candidate.pickupRoute().states.getLast().getTime() - ); + public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) { + // Get shared route segments (passenger pickup to dropoff) + var sharedSegments = candidate.getSharedSegments(); + if (sharedSegments.isEmpty()) { + return null; + } + + // Calculate times + var pickupSegments = candidate.getPickupSegments(); + Duration pickupDuration = Duration.ZERO; + for (var segment : pickupSegments) { + pickupDuration = pickupDuration.plus( + Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) + ); + } var driverPickupTime = candidate.trip().startTime().plus(pickupDuration); - // Main carpool leg (passenger origin to destination via shared route) - // Start time is max of request dateTime and driverPickupTime + // Passenger start time is max of request time and when driver arrives var startTime = request.dateTime().isAfter(driverPickupTime.toInstant()) ? request.dateTime().atZone(ZoneId.of("Europe/Oslo")) : driverPickupTime; - var carpoolDuration = Duration.between( - candidate.sharedRoute().states.getFirst().getTime(), - candidate.sharedRoute().states.getLast().getTime() - ); + // Calculate shared journey duration + Duration carpoolDuration = Duration.ZERO; + for (var segment : sharedSegments) { + carpoolDuration = carpoolDuration.plus( + Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) + ); + } var endTime = startTime.plus(carpoolDuration); - var fromVertex = candidate.passengerOrigin().iterator().next(); - var toVertex = candidate.passengerDestination().iterator().next(); + // Get vertices from first and last segment + var firstSegment = sharedSegments.get(0); + var lastSegment = sharedSegments.get(sharedSegments.size() - 1); + + Vertex fromVertex = firstSegment.states.getFirst().getVertex(); + Vertex toVertex = lastSegment.states.getLast().getVertex(); + + // Collect all edges from shared segments + var allEdges = sharedSegments.stream().flatMap(seg -> seg.edges.stream()).toList(); + // Create carpool leg CarpoolLeg carpoolLeg = CarpoolLeg.of() .withStartTime(startTime) .withEndTime(endTime) .withFrom(Place.normal(fromVertex, new NonLocalizedString("Carpool boarding"))) .withTo(Place.normal(toVertex, new NonLocalizedString("Carpool alighting"))) - .withGeometry( - GeometryUtils.concatenateLineStrings(candidate.sharedRoute().edges, Edge::getGeometry) - ) - .withDistanceMeters( - candidate.sharedRoute().edges.stream().mapToDouble(Edge::getDistanceMeters).sum() - ) - .withGeneralizedCost((int) candidate.sharedRoute().getWeight()) + .withGeometry(GeometryUtils.concatenateLineStrings(allEdges, Edge::getGeometry)) + .withDistanceMeters(allEdges.stream().mapToDouble(Edge::getDistanceMeters).sum()) + .withGeneralizedCost((int) lastSegment.getWeight()) .build(); return Itinerary.ofDirect(List.of(carpoolLeg)) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java deleted file mode 100644 index 46cb3a7293b..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingService.java +++ /dev/null @@ -1,312 +0,0 @@ -package org.opentripplanner.ext.carpooling.internal; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import javax.annotation.Nullable; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; -import org.opentripplanner.astar.strategy.PathComparator; -import org.opentripplanner.ext.carpooling.CarpoolingRepository; -import org.opentripplanner.ext.carpooling.CarpoolingService; -import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.model.GenericLocation; -import org.opentripplanner.model.plan.Itinerary; -import org.opentripplanner.routing.api.request.RouteRequest; -import org.opentripplanner.routing.api.request.StreetMode; -import org.opentripplanner.routing.api.request.preference.StreetPreferences; -import org.opentripplanner.routing.api.request.request.StreetRequest; -import org.opentripplanner.routing.api.response.InputField; -import org.opentripplanner.routing.api.response.RoutingError; -import org.opentripplanner.routing.api.response.RoutingErrorCode; -import org.opentripplanner.routing.error.RoutingValidationException; -import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.routing.linking.VertexLinker; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.StreetSearchBuilder; -import org.opentripplanner.street.search.TemporaryVerticesContainer; -import org.opentripplanner.street.search.state.State; -import org.opentripplanner.street.search.strategy.DominanceFunctions; -import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; -import org.opentripplanner.street.service.StreetLimitationParametersService; - -public class DefaultCarpoolingService implements CarpoolingService { - - private static final Duration MAX_BOOKING_WINDOW = Duration.ofHours(2); - private static final int DEFAULT_MAX_CARPOOL_RESULTS = 3; - - private final StreetLimitationParametersService streetLimitationParametersService; - private final CarpoolingRepository repository; - private final Graph graph; - private final VertexLinker vertexLinker; - - public DefaultCarpoolingService( - StreetLimitationParametersService streetLimitationParametersService, - CarpoolingRepository repository, - Graph graph, - VertexLinker vertexLinker - ) { - this.streetLimitationParametersService = streetLimitationParametersService; - this.repository = repository; - this.graph = graph; - this.vertexLinker = vertexLinker; - } - - /** - * VIA-SEARCH ALGORITHM FOR CARPOOLING INTEGRATION - * - *

-   * The core challenge of carpooling integration is matching passengers with drivers
-   * based on route compatibility. This is fundamentally a via-point routing problem
-   * where we need to determine if a driver's journey from A→B can accommodate a
-   * passenger's journey from C→D within the driver's stated deviation tolerance.
-   *
-   * Algorithm Overview:
-   * 1. Driver has a baseline route from origin A to destination B
-   * 2. Driver specifies a deviation tolerance (deviationBudget in CarpoolTrip)
-   * 3. Passenger requests travel from origin C to destination D
-   * 4. Algorithm checks if route A→C→D→B stays within constraint:
-   *    total_time ≤ baseline_time + deviation_tolerance
-   *
-   * Multi-Stage Processing:
-   * Stage 1: Get all available carpool trips from repository
-   * Stage 2: For each trip, calculate baseline route time (A→B)
-   * Stage 3: Calculate via-route segments:
-   *   - A→C: Driver's detour to pickup point
-   *   - C→D: Shared journey segment
-   *   - D→B: Driver's continuation to final destination
-   * Stage 4: Feasibility check - compare total time vs baseline + deviationBudget
-   * Stage 5: Return viable matches ranked by efficiency
-   * 
- */ - public List route(RouteRequest request) throws RoutingValidationException { - if ( - Objects.requireNonNull(request.from()).lat == null || - Objects.requireNonNull(request.from()).lng == null - ) { - throw new RoutingValidationException( - List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.FROM_PLACE)) - ); - } - if ( - Objects.requireNonNull(request.to()).lat == null || - Objects.requireNonNull(request.to()).lng == null - ) { - throw new RoutingValidationException( - List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.TO_PLACE)) - ); - } - - // Get all available carpool trips from repository - List availableTrips = repository - .getCarpoolTrips() - .stream() - .filter(trip -> { - // Only include trips that start within the next 2 hours - var tripStartTime = trip.startTime().toInstant(); - var requestTime = request.dateTime(); - return ( - // Currently we only include trips that start after the request time - // We should also consider trips that start before request time and make it to the - // pickup location on time. - tripStartTime.isAfter(requestTime) && - tripStartTime.isBefore(requestTime.plus(MAX_BOOKING_WINDOW)) - ); - }) - .toList(); - - if (availableTrips.isEmpty()) { - return Collections.emptyList(); - } - - // Evaluate each carpool trip using via-search algorithm - List viableCandidates = new ArrayList<>(); - - for (CarpoolTrip trip : availableTrips) { - ViaCarpoolCandidate candidate = evaluateViaRouteForTrip(request, trip); - if (candidate != null) { - viableCandidates.add(candidate); - } - } - - // Sort candidates by efficiency (additional time for driver) - viableCandidates.sort(Comparator.comparing(ViaCarpoolCandidate::viaDeviation)); - - // Create itineraries for top results - List itineraries = new ArrayList<>(); - int maxResults = Math.min(viableCandidates.size(), DEFAULT_MAX_CARPOOL_RESULTS); - - for (int i = 0; i < maxResults; i++) { - ViaCarpoolCandidate candidate = viableCandidates.get(i); - Itinerary itinerary = CarpoolItineraryMapper.mapViaRouteToItinerary(request, candidate); - if (itinerary != null) { - itineraries.add(itinerary); - } - } - - return itineraries; - } - - /** - * Evaluate a single carpool trip using the via-search algorithm. - * Returns a viable candidate if the route A→C→D→B stays within the deviationBudget. - */ - private ViaCarpoolCandidate evaluateViaRouteForTrip(RouteRequest request, CarpoolTrip trip) { - TemporaryVerticesContainer acTempVertices; - try { - acTempVertices = new TemporaryVerticesContainer( - graph, - vertexLinker, - null, - GenericLocation.fromCoordinate(trip.boardingArea().getLat(), trip.boardingArea().getLon()), - GenericLocation.fromCoordinate(request.from().lat, request.from().lng), - StreetMode.CAR, // We'll route by car for all segments - StreetMode.CAR - ); - } catch (Exception e) { - throw new RuntimeException(e); - } - - TemporaryVerticesContainer cdTempVertices; - try { - cdTempVertices = new TemporaryVerticesContainer( - graph, - vertexLinker, - null, - GenericLocation.fromCoordinate(request.from().lat, request.from().lng), - GenericLocation.fromCoordinate(request.to().lat, request.to().lng), - StreetMode.CAR, // We'll route by car for all segments - StreetMode.CAR - ); - } catch (Exception e) { - throw new RuntimeException(e); - } - - TemporaryVerticesContainer dbTempVertices; - try { - dbTempVertices = new TemporaryVerticesContainer( - graph, - vertexLinker, - null, - GenericLocation.fromCoordinate(request.to().lat, request.to().lng), - GenericLocation.fromCoordinate( - trip.alightingArea().getLat(), - trip.alightingArea().getLon() - ), - StreetMode.CAR, // We'll route by car for all segments - StreetMode.CAR - ); - } catch (Exception e) { - throw new RuntimeException(e); - } - - // Calculate via-route segments: A→C→D→B - GraphPath pickupRoute = performCarRouting( - request, - acTempVertices.getFromVertices(), - acTempVertices.getToVertices() - ); - GraphPath sharedRoute = performCarRouting( - request, - cdTempVertices.getFromVertices(), - cdTempVertices.getToVertices() - ); - GraphPath dropoffRoute = performCarRouting( - request, - dbTempVertices.getFromVertices(), - dbTempVertices.getToVertices() - ); - - if (pickupRoute == null || sharedRoute == null || dropoffRoute == null) { - return null; // Failed to calculate some segments - } - - // Calculate total travel times - var viaDuration = routeDuration(pickupRoute) - .plus(routeDuration(sharedRoute)) - .plus(routeDuration(dropoffRoute)); - - // Check if within deviation budget - var deviationDuration = viaDuration.minus(trip.tripDuration()); - if (deviationDuration.compareTo(trip.deviationBudget()) > 0) { - return null; // Exceeds deviation budget - } - - return new ViaCarpoolCandidate( - trip, - pickupRoute, - sharedRoute, - deviationDuration, - viaDuration, - cdTempVertices.getFromVertices(), - cdTempVertices.getToVertices() - ); - } - - /** - * Performs car routing between two vertices. - */ - private GraphPath performCarRouting( - RouteRequest request, - Set from, - Set to - ) { - return carpoolRouting( - request, - new StreetRequest(StreetMode.CAR), - from, - to, - streetLimitationParametersService.getMaxCarSpeed() - ); - } - - /** - * Calculate the travel time in seconds for a given route. - */ - private Duration routeDuration(GraphPath route) { - if (route == null || route.states.isEmpty()) { - return Duration.ZERO; - } - return Duration.between(route.states.getFirst().getTime(), route.states.getLast().getTime()); - } - - /** - * Performs A* street routing between two coordinates using the specified street mode. - * Returns the routing result with distance, time, and geometry. - */ - @Nullable - private GraphPath carpoolRouting( - RouteRequest request, - StreetRequest streetRequest, - Set from, - Set to, - float maxCarSpeed - ) { - StreetPreferences preferences = request.preferences().street(); - - var streetSearch = StreetSearchBuilder.of() - .setHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) - .setSkipEdgeStrategy( - new DurationSkipEdgeStrategy(preferences.maxDirectDuration().valueOf(streetRequest.mode())) - ) - .setDominanceFunction(new DominanceFunctions.MinimumWeight()) - .setRequest(request) - .setStreetRequest(streetRequest) - .setFrom(from) - .setTo(to); - - List> paths = streetSearch.getPathsToTarget(); - paths.sort(new PathComparator(request.arriveBy())); - - if (paths.isEmpty()) { - return null; - } - - return paths.getFirst(); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java deleted file mode 100644 index eb21397839c..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/ViaCarpoolCandidate.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.opentripplanner.ext.carpooling.internal; - -import java.time.Duration; -import java.util.Set; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.state.State; - -/** - * Represents a viable carpool match with via-route timing information - */ -public record ViaCarpoolCandidate( - CarpoolTrip trip, - GraphPath pickupRoute, // A→C (driver to passenger pickup) - GraphPath sharedRoute, // C→D (shared journey) - Duration baselineDuration, - Duration viaDuration, - Set passengerOrigin, - Set passengerDestination -) { - public Duration viaDeviation() { - return viaDuration.minus(baselineDuration); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidate.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidate.java new file mode 100644 index 00000000000..b4d5fe4469f --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidate.java @@ -0,0 +1,83 @@ +package org.opentripplanner.ext.carpooling.routing; + +import java.time.Duration; +import java.util.List; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +/** + * Represents a viable insertion of a passenger into a carpool trip. + *

+ * Contains all information needed to construct an itinerary, including: + * - The original trip + * - Insertion positions (where pickup and dropoff occur in the route) + * - Route segments (all GraphPaths forming the complete modified route) + * - Timing information (baseline and total duration, deviation) + */ +public record InsertionCandidate( + CarpoolTrip trip, + int pickupPosition, + int dropoffPosition, + List> routeSegments, + Duration baselineDuration, + Duration totalDuration +) { + /** + * Calculates the additional duration caused by inserting this passenger. + */ + public Duration additionalDuration() { + return totalDuration.minus(baselineDuration); + } + + /** + * Checks if this insertion is within the trip's deviation budget. + */ + public boolean isWithinDeviationBudget() { + return additionalDuration().compareTo(trip.deviationBudget()) <= 0; + } + + /** + * Gets the pickup route segment(s) - from boarding to passenger pickup. + * Returns all segments before the pickup position. + */ + public List> getPickupSegments() { + if (pickupPosition == 0) { + return List.of(); + } + return routeSegments.subList(0, pickupPosition); + } + + /** + * Gets the shared route segment(s) - from passenger pickup to dropoff. + * Returns all segments between pickup and dropoff positions. + */ + public List> getSharedSegments() { + return routeSegments.subList(pickupPosition, dropoffPosition); + } + + /** + * Gets the dropoff route segment(s) - from passenger dropoff to alighting. + * Returns all segments after the dropoff position. + */ + public List> getDropoffSegments() { + if (dropoffPosition >= routeSegments.size()) { + return List.of(); + } + return routeSegments.subList(dropoffPosition, routeSegments.size()); + } + + @Override + public String toString() { + return String.format( + "InsertionCandidate{trip=%s, pickup@%d, dropoff@%d, additional=%ds, segments=%d}", + trip.getId(), + pickupPosition, + dropoffPosition, + additionalDuration().getSeconds(), + routeSegments.size() + ); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java new file mode 100644 index 00000000000..facb8129be7 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java @@ -0,0 +1,256 @@ +package org.opentripplanner.ext.carpooling.routing; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.model.CarpoolStop; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Finds the optimal insertion positions for a passenger in a carpool trip. + *

+ * Uses a brute-force approach to try all valid insertion combinations and + * selects the one with minimum additional travel time. Delegates validation + * to pluggable validators. + *

+ * Algorithm: + * 1. Build route points from the trip + * 2. For each possible pickup position: + * - For each possible dropoff position after pickup: + * - Validate the insertion + * - Calculate route with actual A* routing + * - Track the best (minimum additional duration) + * 3. Return the optimal candidate + */ +public class OptimalInsertionStrategy { + + private static final Logger LOG = LoggerFactory.getLogger(OptimalInsertionStrategy.class); + + private final InsertionValidator validator; + private final RoutingFunction routingFunction; + + public OptimalInsertionStrategy(InsertionValidator validator, RoutingFunction routingFunction) { + this.validator = validator; + this.routingFunction = routingFunction; + } + + /** + * Finds the optimal insertion for a passenger in a trip. + * + * @param trip The carpool trip + * @param passengerPickup Passenger's pickup location + * @param passengerDropoff Passenger's dropoff location + * @return The optimal insertion candidate, or null if no valid insertion exists + */ + public InsertionCandidate findOptimalInsertion( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + // Build route points and passenger timeline + List routePoints = buildRoutePoints(trip); + PassengerCountTimeline passengerTimeline = PassengerCountTimeline.build(trip); + + LOG.debug( + "Evaluating insertion for trip {} with {} route points, {} capacity", + trip.getId(), + routePoints.size(), + trip.availableSeats() + ); + + // Calculate baseline duration (current route without new passenger) + Duration baselineDuration = calculateRouteDuration(routePoints); + if (baselineDuration == null) { + LOG.warn("Could not calculate baseline duration for trip {}", trip.getId()); + return null; + } + + InsertionCandidate bestCandidate = null; + Duration minAdditionalDuration = Duration.ofDays(1); + + // Try all valid insertion positions + for (int pickupPos = 1; pickupPos <= routePoints.size(); pickupPos++) { + for (int dropoffPos = pickupPos + 1; dropoffPos <= routePoints.size() + 1; dropoffPos++) { + // Create validation context + List routeCoords = routePoints.stream().map(RoutePoint::coordinate).toList(); + + var validationContext = new InsertionValidator.ValidationContext( + pickupPos, + dropoffPos, + passengerPickup, + passengerDropoff, + routeCoords, + passengerTimeline + ); + + // Validate insertion + var validationResult = validator.validate(validationContext); + if (!validationResult.isValid()) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected: {}", + pickupPos, + dropoffPos, + validationResult.reason() + ); + continue; + } + + // Calculate route with insertion + InsertionCandidate candidate = evaluateInsertion( + trip, + routePoints, + pickupPos, + dropoffPos, + passengerPickup, + passengerDropoff, + baselineDuration + ); + + if (candidate != null) { + Duration additionalDuration = candidate.additionalDuration(); + + // Check if this is the best so far and within deviation budget + if ( + additionalDuration.compareTo(minAdditionalDuration) < 0 && + additionalDuration.compareTo(trip.deviationBudget()) <= 0 + ) { + minAdditionalDuration = additionalDuration; + bestCandidate = candidate; + LOG.debug( + "New best insertion: pickup@{}, dropoff@{}, additional={}s", + pickupPos, + dropoffPos, + additionalDuration.getSeconds() + ); + } + } + } + } + + if (bestCandidate == null) { + LOG.debug("No valid insertion found for trip {}", trip.getId()); + } else { + LOG.info( + "Optimal insertion for trip {}: pickup@{}, dropoff@{}, additional={}s", + trip.getId(), + bestCandidate.pickupPosition(), + bestCandidate.dropoffPosition(), + bestCandidate.additionalDuration().getSeconds() + ); + } + + return bestCandidate; + } + + /** + * Evaluates a specific insertion configuration by routing all segments. + */ + private InsertionCandidate evaluateInsertion( + CarpoolTrip trip, + List originalPoints, + int pickupPos, + int dropoffPos, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + Duration baselineDuration + ) { + // Build modified route with passenger stops inserted + List modifiedPoints = new ArrayList<>(originalPoints); + modifiedPoints.add(pickupPos, new RoutePoint(passengerPickup, "Passenger-Pickup")); + modifiedPoints.add(dropoffPos, new RoutePoint(passengerDropoff, "Passenger-Dropoff")); + + // Route all segments + List> segments = new ArrayList<>(); + Duration totalDuration = Duration.ZERO; + + for (int i = 0; i < modifiedPoints.size() - 1; i++) { + GenericLocation from = toGenericLocation(modifiedPoints.get(i).coordinate()); + GenericLocation to = toGenericLocation(modifiedPoints.get(i + 1).coordinate()); + + GraphPath segment = routingFunction.route(from, to); + if (segment == null) { + LOG.trace("Routing failed for segment {} → {}", i, i + 1); + return null; // This insertion is not viable + } + + segments.add(segment); + totalDuration = totalDuration.plus( + Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) + ); + } + + return new InsertionCandidate( + trip, + pickupPos, + dropoffPos, + segments, + baselineDuration, + totalDuration + ); + } + + /** + * Builds route points from a trip. + */ + private List buildRoutePoints(CarpoolTrip trip) { + List points = new ArrayList<>(); + + // Boarding area + points.add(new RoutePoint(trip.boardingArea().getCoordinate(), "Boarding-" + trip.getId())); + + // Existing stops + for (CarpoolStop stop : trip.stops()) { + points.add(new RoutePoint(stop.getCoordinate(), "Stop-" + stop.getSequenceNumber())); + } + + // Alighting area + points.add(new RoutePoint(trip.alightingArea().getCoordinate(), "Alighting-" + trip.getId())); + + return points; + } + + /** + * Calculates the total duration for a route. + */ + private Duration calculateRouteDuration(List routePoints) { + Duration total = Duration.ZERO; + + for (int i = 0; i < routePoints.size() - 1; i++) { + GenericLocation from = toGenericLocation(routePoints.get(i).coordinate()); + GenericLocation to = toGenericLocation(routePoints.get(i + 1).coordinate()); + + GraphPath segment = routingFunction.route(from, to); + if (segment == null) { + return null; + } + + total = total.plus( + Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) + ); + } + + return total; + } + + private GenericLocation toGenericLocation(WgsCoordinate coord) { + return GenericLocation.fromCoordinate(coord.latitude(), coord.longitude()); + } + + /** + * Functional interface for street routing. + */ + @FunctionalInterface + public interface RoutingFunction { + GraphPath route(GenericLocation from, GenericLocation to); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java new file mode 100644 index 00000000000..78d4f88ec03 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java @@ -0,0 +1,25 @@ +package org.opentripplanner.ext.carpooling.routing; + +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Represents a point along a carpool route. + *

+ * Route points include the boarding area, intermediate stops, and alighting area. + * Each point has a coordinate and a descriptive label for debugging. + */ +public record RoutePoint(WgsCoordinate coordinate, String label) { + public RoutePoint { + if (coordinate == null) { + throw new IllegalArgumentException("Coordinate cannot be null"); + } + if (label == null || label.isBlank()) { + throw new IllegalArgumentException("Label cannot be null or blank"); + } + } + + @Override + public String toString() { + return String.format("%s (%.4f, %.4f)", label, coordinate.latitude(), coordinate.longitude()); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java new file mode 100644 index 00000000000..87bfaef4b5b --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -0,0 +1,233 @@ +package org.opentripplanner.ext.carpooling.service; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; +import org.opentripplanner.astar.strategy.PathComparator; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.carpooling.filter.FilterChain; +import org.opentripplanner.ext.carpooling.internal.CarpoolItineraryMapper; +import org.opentripplanner.ext.carpooling.routing.InsertionCandidate; +import org.opentripplanner.ext.carpooling.routing.OptimalInsertionStrategy; +import org.opentripplanner.ext.carpooling.validation.CompositeValidator; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.model.plan.Itinerary; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.request.StreetRequest; +import org.opentripplanner.routing.api.response.InputField; +import org.opentripplanner.routing.api.response.RoutingError; +import org.opentripplanner.routing.api.response.RoutingErrorCode; +import org.opentripplanner.routing.error.RoutingValidationException; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.VertexLinker; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.StreetSearchBuilder; +import org.opentripplanner.street.search.TemporaryVerticesContainer; +import org.opentripplanner.street.search.state.State; +import org.opentripplanner.street.search.strategy.DominanceFunctions; +import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; +import org.opentripplanner.street.service.StreetLimitationParametersService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Refactored carpooling service using the new modular architecture. + *

+ * Orchestrates: + * - Pre-filtering trips with FilterChain + * - Finding optimal insertions with OptimalInsertionStrategy + * - Mapping results to itineraries with CarpoolItineraryMapper + */ +public class DefaultCarpoolingService implements CarpoolingService { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultCarpoolingService.class); + private static final int DEFAULT_MAX_CARPOOL_RESULTS = 3; + + private final CarpoolingRepository repository; + private final Graph graph; + private final VertexLinker vertexLinker; + private final StreetLimitationParametersService streetLimitationParametersService; + private final FilterChain preFilters; + private final CompositeValidator insertionValidator; + private final CarpoolItineraryMapper itineraryMapper; + + public DefaultCarpoolingService( + CarpoolingRepository repository, + Graph graph, + VertexLinker vertexLinker, + StreetLimitationParametersService streetLimitationParametersService + ) { + this.repository = repository; + this.graph = graph; + this.vertexLinker = vertexLinker; + this.streetLimitationParametersService = streetLimitationParametersService; + this.preFilters = FilterChain.standard(); + this.insertionValidator = CompositeValidator.standard(); + this.itineraryMapper = new CarpoolItineraryMapper(); + } + + @Override + public List route(RouteRequest request) throws RoutingValidationException { + // Validate request + validateRequest(request); + + // Extract passenger coordinates + WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate()); + WgsCoordinate passengerDropoff = new WgsCoordinate(request.to().getCoordinate()); + + LOG.debug("Finding carpool itineraries from {} to {}", passengerPickup, passengerDropoff); + + // Get all trips from repository + var allTrips = repository.getCarpoolTrips(); + LOG.debug("Repository contains {} carpool trips", allTrips.size()); + + // Apply pre-filters (fast rejection) + var candidateTrips = allTrips + .stream() + .filter(trip -> preFilters.accepts(trip, passengerPickup, passengerDropoff)) + .toList(); + + LOG.debug( + "{} trips passed pre-filters ({} rejected)", + candidateTrips.size(), + allTrips.size() - candidateTrips.size() + ); + + if (candidateTrips.isEmpty()) { + return List.of(); + } + + // Create routing function + var routingFunction = createRoutingFunction(request); + + // Create insertion strategy + var insertionStrategy = new OptimalInsertionStrategy(insertionValidator, routingFunction); + + // Find optimal insertions for remaining trips + var insertionCandidates = candidateTrips + .stream() + .map(trip -> insertionStrategy.findOptimalInsertion(trip, passengerPickup, passengerDropoff)) + .filter(Objects::nonNull) + .filter(InsertionCandidate::isWithinDeviationBudget) + .sorted(Comparator.comparing(InsertionCandidate::additionalDuration)) + .limit(DEFAULT_MAX_CARPOOL_RESULTS) + .toList(); + + LOG.debug("Found {} viable insertion candidates", insertionCandidates.size()); + + // Map to itineraries + var itineraries = insertionCandidates + .stream() + .map(candidate -> itineraryMapper.toItinerary(request, candidate)) + .filter(Objects::nonNull) + .toList(); + + LOG.info("Returning {} carpool itineraries", itineraries.size()); + return itineraries; + } + + /** + * Validates the route request. + */ + private void validateRequest(RouteRequest request) throws RoutingValidationException { + if ( + Objects.requireNonNull(request.from()).lat == null || + Objects.requireNonNull(request.from()).lng == null + ) { + throw new RoutingValidationException( + List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.FROM_PLACE)) + ); + } + if ( + Objects.requireNonNull(request.to()).lat == null || + Objects.requireNonNull(request.to()).lng == null + ) { + throw new RoutingValidationException( + List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.TO_PLACE)) + ); + } + } + + /** + * Creates a routing function that performs A* street routing. + */ + private OptimalInsertionStrategy.RoutingFunction createRoutingFunction(RouteRequest request) { + return (from, to) -> { + try { + var tempVertices = new TemporaryVerticesContainer( + graph, + vertexLinker, + null, + from, + to, + StreetMode.CAR, + StreetMode.CAR + ); + + return performCarRouting( + request, + tempVertices.getFromVertices(), + tempVertices.getToVertices() + ); + } catch (Exception e) { + LOG.warn("Routing failed from {} to {}: {}", from, to, e.getMessage()); + return null; + } + }; + } + + /** + * Performs A* car routing between two vertex sets. + */ + private GraphPath performCarRouting( + RouteRequest request, + java.util.Set from, + java.util.Set to + ) { + return carpoolRouting( + request, + new StreetRequest(StreetMode.CAR), + from, + to, + streetLimitationParametersService.getMaxCarSpeed() + ); + } + + /** + * Core A* routing for carpooling (optimized for car travel). + */ + private GraphPath carpoolRouting( + RouteRequest routeRequest, + StreetRequest streetRequest, + java.util.Set fromVertices, + java.util.Set toVertices, + float maxCarSpeed + ) { + var preferences = routeRequest.preferences().street(); + + var streetSearch = StreetSearchBuilder.of() + .withHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) + .withSkipEdgeStrategy( + new DurationSkipEdgeStrategy(preferences.maxDirectDuration().valueOf(streetRequest.mode())) + ) + .withDominanceFunction(new DominanceFunctions.MinimumWeight()) + .withRequest(routeRequest) + .withStreetRequest(streetRequest) + .withFrom(fromVertices) + .withTo(toVertices); + + List> paths = streetSearch.getPathsToTarget(); + paths.sort(new PathComparator(routeRequest.arriveBy())); + + if (paths.isEmpty()) { + return null; + } + + return paths.getFirst(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index de47ae02215..8522c7eee33 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -118,7 +118,7 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { .withEndTime(endTime) .withProvider(provider) .withDeviationBudget(deviationBudget) - .withAvailableSeats(1) // Default value, could be enhanced if data available + .withAvailableSeats(2) // Default value, could be enhanced if data available .withStops(stops) .build(); } @@ -179,13 +179,13 @@ private GraphPath performCarpoolRouting(Set from, S float maxCarSpeed = streetLimitationParametersService.getMaxCarSpeed(); var streetSearch = StreetSearchBuilder.of() - .setHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) - .setSkipEdgeStrategy(new DurationSkipEdgeStrategy<>(MAX_ROUTE_DURATION)) - .setDominanceFunction(new DominanceFunctions.MinimumWeight()) - .setRequest(request) - .setStreetRequest(streetRequest) - .setFrom(from) - .setTo(to); + .withHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) + .withSkipEdgeStrategy(new DurationSkipEdgeStrategy<>(MAX_ROUTE_DURATION)) + .withDominanceFunction(new DominanceFunctions.MinimumWeight()) + .withRequest(request) + .withStreetRequest(streetRequest) + .withFrom(from) + .withTo(to); List> paths = streetSearch.getPathsToTarget(); @@ -338,7 +338,9 @@ private void validateEstimatedCallOrder(List calls) { // Validate intermediate calls are between first and last for (int i = 1; i < calls.size() - 1; i++) { EstimatedCall intermediateCall = calls.get(i); - ZonedDateTime intermediateTime = intermediateCall.getAimedDepartureTime() != null ? intermediateCall.getAimedDepartureTime() : intermediateCall.getAimedArrivalTime(); + ZonedDateTime intermediateTime = intermediateCall.getAimedDepartureTime() != null + ? intermediateCall.getAimedDepartureTime() + : intermediateCall.getAimedArrivalTime(); if (intermediateTime == null) { LOG.warn("Intermediate call at index {} has no timing information", i); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java new file mode 100644 index 00000000000..14ab9867fc8 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java @@ -0,0 +1,185 @@ +package org.opentripplanner.ext.carpooling.util; + +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Calculates bearings and directional relationships between geographic coordinates. + *

+ * Uses the Haversine formula for accurate bearing calculations on Earth's surface. + * All bearings are in degrees [0, 360) where 0° = North, 90° = East, etc. + */ +public class DirectionalCalculator { + + /** + * Calculates the initial bearing (forward azimuth) from one point to another. + *

+ * Uses the Haversine formula for accurate bearing on Earth's surface. + * + * @param from Starting point + * @param to Ending point + * @return Bearing in degrees [0, 360), where 0° is North + */ + public static double calculateBearing(WgsCoordinate from, WgsCoordinate to) { + double lat1 = Math.toRadians(from.latitude()); + double lat2 = Math.toRadians(to.latitude()); + double lon1 = Math.toRadians(from.longitude()); + double lon2 = Math.toRadians(to.longitude()); + + double dLon = lon2 - lon1; + + double y = Math.sin(dLon) * Math.cos(lat2); + double x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + + double bearing = Math.toDegrees(Math.atan2(y, x)); + + // Normalize to [0, 360) + return (bearing + 360.0) % 360.0; + } + + /** + * Calculates the angular difference between two bearings. + *

+ * Returns the smallest angle between the two bearings, accounting for + * the circular nature of bearings (e.g., 10° and 350° are only 20° apart). + * + * @param bearing1 First bearing in degrees [0, 360) + * @param bearing2 Second bearing in degrees [0, 360) + * @return Smallest angular difference in degrees [0, 180] + */ + public static double bearingDifference(double bearing1, double bearing2) { + double diff = Math.abs(bearing1 - bearing2); + + // Take the smaller angle (handle wrap-around) + if (diff > 180.0) { + diff = 360.0 - diff; + } + + return diff; + } + + /** + * Categorizes the directional relationship between two bearings. + */ + public enum DirectionalAlignment { + /** Directions are very similar (within 30°) - ideal match */ + HIGHLY_ALIGNED, + + /** Directions are compatible (within 60°) - acceptable match */ + ALIGNED, + + /** Directions differ but not opposite (60-120°) - marginal */ + DIVERGENT, + + /** Directions are opposite or very different (>120°) - incompatible */ + OPPOSITE; + + public static DirectionalAlignment categorize(double bearingDifference) { + if (bearingDifference <= 30.0) { + return HIGHLY_ALIGNED; + } else if (bearingDifference <= 60.0) { + return ALIGNED; + } else if (bearingDifference <= 120.0) { + return DIVERGENT; + } else { + return OPPOSITE; + } + } + } + + /** + * Classifies the directional alignment between two journeys. + * + * @param tripStart Starting point of the trip + * @param tripEnd Ending point of the trip + * @param passengerStart Passenger's starting point + * @param passengerEnd Passenger's ending point + * @return The alignment category + */ + public static DirectionalAlignment classify( + WgsCoordinate tripStart, + WgsCoordinate tripEnd, + WgsCoordinate passengerStart, + WgsCoordinate passengerEnd + ) { + double tripBearing = calculateBearing(tripStart, tripEnd); + double passengerBearing = calculateBearing(passengerStart, passengerEnd); + double difference = bearingDifference(tripBearing, passengerBearing); + + return DirectionalAlignment.categorize(difference); + } + + /** + * Checks if a passenger journey is directionally compatible with a carpool trip. + * + * @param tripStart Starting point of the carpool trip + * @param tripEnd Ending point of the carpool trip + * @param passengerStart Passenger's desired pickup location + * @param passengerEnd Passenger's desired dropoff location + * @param toleranceDegrees Maximum allowed bearing difference in degrees + * @return true if directions are compatible, false otherwise + */ + public static boolean isDirectionallyCompatible( + WgsCoordinate tripStart, + WgsCoordinate tripEnd, + WgsCoordinate passengerStart, + WgsCoordinate passengerEnd, + double toleranceDegrees + ) { + double tripBearing = calculateBearing(tripStart, tripEnd); + double passengerBearing = calculateBearing(passengerStart, passengerEnd); + double difference = bearingDifference(tripBearing, passengerBearing); + + return difference <= toleranceDegrees; + } + + /** + * Checks if adding a new point maintains forward progress along a route. + * + * @param previous Previous point in the route + * @param newPoint Point to be inserted + * @param next Next point in the route + * @param toleranceDegrees Maximum allowed deviation in degrees + * @return true if insertion maintains forward progress, false if it causes backtracking + */ + public static boolean maintainsForwardProgress( + WgsCoordinate previous, + WgsCoordinate newPoint, + WgsCoordinate next, + double toleranceDegrees + ) { + double intendedBearing = calculateBearing(previous, next); + double bearingToNew = calculateBearing(previous, newPoint); + double bearingFromNew = calculateBearing(newPoint, next); + + double deviationToNew = bearingDifference(intendedBearing, bearingToNew); + double deviationFromNew = bearingDifference(intendedBearing, bearingFromNew); + + return deviationToNew <= toleranceDegrees && deviationFromNew <= toleranceDegrees; + } + + /** + * Classifies directional alignment using custom thresholds. + * + * @param bearingDifference The bearing difference in degrees + * @param highlyAlignedThreshold Threshold for HIGHLY_ALIGNED (e.g., 30.0) + * @param alignedThreshold Threshold for ALIGNED (e.g., 60.0) + * @param divergentThreshold Threshold for DIVERGENT (e.g., 120.0) + * @return The alignment category + */ + public static DirectionalAlignment classify( + double bearingDifference, + double highlyAlignedThreshold, + double alignedThreshold, + double divergentThreshold + ) { + if (bearingDifference <= highlyAlignedThreshold) { + return DirectionalAlignment.HIGHLY_ALIGNED; + } else if (bearingDifference <= alignedThreshold) { + return DirectionalAlignment.ALIGNED; + } else if (bearingDifference <= divergentThreshold) { + return DirectionalAlignment.DIVERGENT; + } else { + return DirectionalAlignment.OPPOSITE; + } + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java new file mode 100644 index 00000000000..40e4ca201be --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java @@ -0,0 +1,141 @@ +package org.opentripplanner.ext.carpooling.util; + +import java.util.ArrayList; +import java.util.List; +import org.opentripplanner.ext.carpooling.model.CarpoolStop; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; + +/** + * Tracks passenger counts at each position in a carpool route. + *

+ * Index i represents the number of passengers AFTER position i (before the next segment). + *

+ * Example: + *

+ * Position:     0(Boarding)  1(Stop1)  2(Stop2)  3(Alighting)
+ * Delta:              -         +2        -1           -
+ * Passengers:    0        →  2      →  1       →  0
+ * Timeline:     [0,           2,        1,           0]
+ * 
+ */ +public class PassengerCountTimeline { + + private final List counts; + private final int capacity; + + private PassengerCountTimeline(List counts, int capacity) { + this.counts = counts; + this.capacity = capacity; + } + + /** + * Builds a passenger count timeline from a carpool trip. + * + * @param trip The carpool trip + * @return Timeline tracking passenger counts at each position + */ + public static PassengerCountTimeline build(CarpoolTrip trip) { + List timeline = new ArrayList<>(); + int currentPassengers = 0; + + // Position 0: Boarding (no passengers yet) + timeline.add(currentPassengers); + + // Add passenger delta for each stop + for (CarpoolStop stop : trip.stops()) { + currentPassengers += stop.getPassengerDelta(); + timeline.add(currentPassengers); + } + + // Position N+1: Alighting (all passengers leave) + currentPassengers = 0; + timeline.add(currentPassengers); + + return new PassengerCountTimeline(timeline, trip.availableSeats()); + } + + /** + * Gets the passenger count at a specific position. + * + * @param position Position index + * @return Number of passengers after this position + */ + public int getPassengerCount(int position) { + if (position < 0 || position >= counts.size()) { + throw new IndexOutOfBoundsException( + "Position " + position + " out of bounds (size: " + counts.size() + ")" + ); + } + return counts.get(position); + } + + /** + * Gets the vehicle capacity. + */ + public int getCapacity() { + return capacity; + } + + /** + * Gets the number of positions tracked. + */ + public int size() { + return counts.size(); + } + + /** + * Checks if there's available capacity at a specific position. + * + * @param position Position to check + * @return true if there's at least one available seat + */ + public boolean hasCapacity(int position) { + return getPassengerCount(position) < capacity; + } + + /** + * Checks if there's capacity for a specific number of additional passengers. + * + * @param position Position to check + * @param additionalPassengers Number of passengers to add + * @return true if there's capacity for the additional passengers + */ + public boolean hasCapacityFor(int position, int additionalPassengers) { + return getPassengerCount(position) + additionalPassengers <= capacity; + } + + /** + * Checks if there's capacity throughout a range of positions. + *

+ * This is useful for validating that adding a passenger between pickup and dropoff + * won't exceed capacity at any point along the route. + * + * @param startPosition First position (inclusive) + * @param endPosition Last position (exclusive) + * @param additionalPassengers Number of passengers to add + * @return true if capacity is available throughout the range + */ + public boolean hasCapacityInRange(int startPosition, int endPosition, int additionalPassengers) { + for (int pos = startPosition; pos < endPosition && pos < counts.size(); pos++) { + if (!hasCapacityFor(pos, additionalPassengers)) { + return false; + } + } + return true; + } + + /** + * Gets the available seat count at a position. + * + * @param position Position to check + * @return Number of available seats (capacity - current passengers) + */ + public int getAvailableSeats(int position) { + return capacity - getPassengerCount(position); + } + + @Override + public String toString() { + return "PassengerCountTimeline{counts=" + counts + ", capacity=" + capacity + "}"; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java new file mode 100644 index 00000000000..3c73505dcb5 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java @@ -0,0 +1,124 @@ +package org.opentripplanner.ext.carpooling.util; + +import java.util.List; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Utility methods for working with route geometry and geographic relationships. + */ +public class RouteGeometry { + + /** Tolerance for corridor checks (approximately 10km in degrees at mid-latitudes) */ + public static final double DEFAULT_CORRIDOR_TOLERANCE_DEGREES = 0.1; + + /** + * Represents a geographic bounding box. + */ + public record BoundingBox(double minLat, double maxLat, double minLon, double maxLon) { + /** + * Checks if a coordinate is within this bounding box. + */ + public boolean contains(WgsCoordinate coord) { + return ( + coord.latitude() >= minLat && + coord.latitude() <= maxLat && + coord.longitude() >= minLon && + coord.longitude() <= maxLon + ); + } + + /** + * Expands this bounding box by the given tolerance. + */ + public BoundingBox expand(double tolerance) { + return new BoundingBox( + minLat - tolerance, + maxLat + tolerance, + minLon - tolerance, + maxLon + tolerance + ); + } + } + + /** + * Calculates a bounding box for a list of coordinates. + * + * @param coordinates List of coordinates + * @return Bounding box containing all coordinates + */ + public static BoundingBox calculateBoundingBox(List coordinates) { + if (coordinates.isEmpty()) { + throw new IllegalArgumentException("Cannot calculate bounding box for empty list"); + } + + double minLat = Double.MAX_VALUE; + double maxLat = -Double.MAX_VALUE; + double minLon = Double.MAX_VALUE; + double maxLon = -Double.MAX_VALUE; + + for (WgsCoordinate coord : coordinates) { + minLat = Math.min(minLat, coord.latitude()); + maxLat = Math.max(maxLat, coord.latitude()); + minLon = Math.min(minLon, coord.longitude()); + maxLon = Math.max(maxLon, coord.longitude()); + } + + return new BoundingBox(minLat, maxLat, minLon, maxLon); + } + + /** + * Checks if a coordinate is within a corridor defined by a route segment. + *

+ * This prevents matching passengers who are directionally aligned but geographically + * far from the actual route (e.g., parallel roads on opposite sides of a city). + * + * @param routeSegment Coordinates defining the route corridor + * @param coordinate Coordinate to check + * @param toleranceDegrees Corridor width tolerance in degrees + * @return true if the coordinate is within the corridor + */ + public static boolean isWithinCorridor( + List routeSegment, + WgsCoordinate coordinate, + double toleranceDegrees + ) { + BoundingBox box = calculateBoundingBox(routeSegment); + BoundingBox expandedBox = box.expand(toleranceDegrees); + return expandedBox.contains(coordinate); + } + + /** + * Checks if a coordinate is within a corridor using default tolerance. + */ + public static boolean isWithinCorridor( + List routeSegment, + WgsCoordinate coordinate + ) { + return isWithinCorridor(routeSegment, coordinate, DEFAULT_CORRIDOR_TOLERANCE_DEGREES); + } + + /** + * Checks if both pickup and dropoff are within the route corridor. + */ + public static boolean areBothWithinCorridor( + List routeSegment, + WgsCoordinate pickup, + WgsCoordinate dropoff, + double toleranceDegrees + ) { + BoundingBox box = calculateBoundingBox(routeSegment); + BoundingBox expandedBox = box.expand(toleranceDegrees); + return expandedBox.contains(pickup) && expandedBox.contains(dropoff); + } + + /** + * Checks if both coordinates are within the corridor using default tolerance. + */ + public static boolean areBothWithinCorridor( + List routeSegment, + WgsCoordinate pickup, + WgsCoordinate dropoff + ) { + return areBothWithinCorridor(routeSegment, pickup, dropoff, DEFAULT_CORRIDOR_TOLERANCE_DEGREES); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java new file mode 100644 index 00000000000..59a67a3430b --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java @@ -0,0 +1,52 @@ +package org.opentripplanner.ext.carpooling.validation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates that inserting a passenger won't exceed vehicle capacity. + *

+ * Checks all positions between pickup and dropoff to ensure capacity + * constraints are maintained throughout the passenger's journey. + */ +public class CapacityValidator implements InsertionValidator { + + private static final Logger LOG = LoggerFactory.getLogger(CapacityValidator.class); + + @Override + public ValidationResult validate(ValidationContext context) { + // Check capacity at pickup position + int pickupPassengers = context + .passengerTimeline() + .getPassengerCount(context.pickupPosition() - 1); + int capacity = context.passengerTimeline().getCapacity(); + + if (pickupPassengers >= capacity) { + String reason = String.format( + "No capacity at pickup position %d: %d passengers, %d capacity", + context.pickupPosition(), + pickupPassengers, + capacity + ); + LOG.debug(reason); + return ValidationResult.invalid(reason); + } + + // Check capacity throughout the journey (pickup to dropoff) + boolean hasCapacity = context + .passengerTimeline() + .hasCapacityInRange(context.pickupPosition(), context.dropoffPosition(), 1); + + if (!hasCapacity) { + String reason = String.format( + "Capacity exceeded between positions %d and %d", + context.pickupPosition(), + context.dropoffPosition() + ); + LOG.debug(reason); + return ValidationResult.invalid(reason); + } + + return ValidationResult.valid(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java new file mode 100644 index 00000000000..c42f6a34919 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java @@ -0,0 +1,57 @@ +package org.opentripplanner.ext.carpooling.validation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Combines multiple insertion validators using AND logic. + *

+ * All validators must pass for the insertion to be considered valid. + * Evaluation stops at the first failure (short-circuit). + */ +public class CompositeValidator implements InsertionValidator { + + private final List validators; + + public CompositeValidator(List validators) { + this.validators = new ArrayList<>(validators); + } + + public CompositeValidator(InsertionValidator... validators) { + this(Arrays.asList(validators)); + } + + /** + * Creates a standard validator with capacity and directional checks. + */ + public static CompositeValidator standard() { + return new CompositeValidator(new CapacityValidator(), new DirectionalValidator()); + } + + @Override + public ValidationResult validate(ValidationContext context) { + for (InsertionValidator validator : validators) { + ValidationResult result = validator.validate(context); + if (!result.isValid()) { + return result; // Short-circuit: return first failure + } + } + return ValidationResult.valid(); // All validators passed + } + + /** + * Adds a validator to the composite. + */ + public CompositeValidator add(InsertionValidator validator) { + validators.add(validator); + return this; + } + + /** + * Gets the number of validators. + */ + public int size() { + return validators.size(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java new file mode 100644 index 00000000000..0bb0b3e7944 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java @@ -0,0 +1,107 @@ +package org.opentripplanner.ext.carpooling.validation; + +import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates that inserting pickup/dropoff points maintains forward progress. + *

+ * Prevents backtracking by checking that insertions don't cause the route + * to deviate too far from its intended direction. + */ +public class DirectionalValidator implements InsertionValidator { + + private static final Logger LOG = LoggerFactory.getLogger(DirectionalValidator.class); + + /** Maximum bearing deviation allowed for forward progress (90° allows detours, prevents U-turns) */ + public static final double FORWARD_PROGRESS_TOLERANCE_DEGREES = 90.0; + + private final double toleranceDegrees; + + public DirectionalValidator() { + this(FORWARD_PROGRESS_TOLERANCE_DEGREES); + } + + public DirectionalValidator(double toleranceDegrees) { + this.toleranceDegrees = toleranceDegrees; + } + + @Override + public ValidationResult validate(ValidationContext context) { + // Validate pickup insertion + if (context.pickupPosition() > 0 && context.pickupPosition() < context.routePoints().size()) { + WgsCoordinate prevPoint = context.routePoints().get(context.pickupPosition() - 1); + WgsCoordinate nextPoint = context.routePoints().get(context.pickupPosition()); + + if (!maintainsForwardProgress(prevPoint, context.pickup(), nextPoint)) { + String reason = String.format( + "Pickup insertion at position %d causes backtracking", + context.pickupPosition() + ); + LOG.debug(reason); + return ValidationResult.invalid(reason); + } + } + + // Validate dropoff insertion (in modified route with pickup already inserted) + // Note: dropoffPosition is in the context of the original route + // After pickup insertion, dropoff is one position later + int dropoffPosInModified = context.dropoffPosition(); + if (dropoffPosInModified > 0 && dropoffPosInModified <= context.routePoints().size()) { + // Get the previous point (which might be the pickup if dropoff is right after) + WgsCoordinate prevPoint; + if (dropoffPosInModified == context.pickupPosition()) { + prevPoint = context.pickup(); // Previous point is the pickup + } else if (dropoffPosInModified - 1 < context.routePoints().size()) { + prevPoint = context.routePoints().get(dropoffPosInModified - 1); + } else { + // Edge case: dropoff at the end + return ValidationResult.valid(); + } + + // Get next point if it exists + if (dropoffPosInModified < context.routePoints().size()) { + WgsCoordinate nextPoint = context.routePoints().get(dropoffPosInModified); + + if (!maintainsForwardProgress(prevPoint, context.dropoff(), nextPoint)) { + String reason = String.format( + "Dropoff insertion at position %d causes backtracking", + context.dropoffPosition() + ); + LOG.debug(reason); + return ValidationResult.invalid(reason); + } + } + } + + return ValidationResult.valid(); + } + + /** + * Checks if inserting a new point maintains forward progress. + */ + private boolean maintainsForwardProgress( + WgsCoordinate previous, + WgsCoordinate newPoint, + WgsCoordinate next + ) { + // Calculate intended direction (previous → next) + double intendedBearing = DirectionalCalculator.calculateBearing(previous, next); + + // Calculate detour directions + double bearingToNew = DirectionalCalculator.calculateBearing(previous, newPoint); + double bearingFromNew = DirectionalCalculator.calculateBearing(newPoint, next); + + // Check deviations + double deviationToNew = DirectionalCalculator.bearingDifference(intendedBearing, bearingToNew); + double deviationFromNew = DirectionalCalculator.bearingDifference( + intendedBearing, + bearingFromNew + ); + + // Allow some deviation but not complete reversal + return (deviationToNew <= toleranceDegrees && deviationFromNew <= toleranceDegrees); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java new file mode 100644 index 00000000000..3a3d1f72411 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java @@ -0,0 +1,88 @@ +package org.opentripplanner.ext.carpooling.validation; + +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Validates whether an insertion of pickup/dropoff points into a route is valid. + *

+ * Validators check specific constraints (capacity, direction, etc.) and can + * reject insertions that violate those constraints. + */ +@FunctionalInterface +public interface InsertionValidator { + /** + * Validates an insertion. + * + * @param context The validation context containing all necessary information + * @return Validation result indicating success or failure with reason + */ + ValidationResult validate(ValidationContext context); + + /** + * Context object containing all information needed for validation. + */ + record ValidationContext( + int pickupPosition, + int dropoffPosition, + WgsCoordinate pickup, + WgsCoordinate dropoff, + java.util.List routePoints, + org.opentripplanner.ext.carpooling.util.PassengerCountTimeline passengerTimeline + ) {} + + /** + * Result of a validation check. + */ + sealed interface ValidationResult { + boolean isValid(); + + String reason(); + + record Valid() implements ValidationResult { + @Override + public boolean isValid() { + return true; + } + + @Override + public String reason() { + return "Valid"; + } + } + + record Invalid(String reason) implements ValidationResult { + @Override + public boolean isValid() { + return false; + } + } + + static ValidationResult valid() { + return new Valid(); + } + + static ValidationResult invalid(String reason) { + return new Invalid(reason); + } + } + + /** + * Returns a validator that always accepts. + */ + static InsertionValidator acceptAll() { + return ctx -> ValidationResult.valid(); + } + + /** + * Combines this validator with another using AND logic. + */ + default InsertionValidator and(InsertionValidator other) { + return ctx -> { + ValidationResult first = this.validate(ctx); + if (!first.isValid()) { + return first; + } + return other.validate(ctx); + }; + } +} diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java b/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java index 49cf10d51fe..0cc7ab5ffda 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/nearbystops/StreetNearbyStopFinder.java @@ -163,7 +163,8 @@ public Collection findNearbyStops( targetVertex instanceof StreetVertex streetVertex && !streetVertex.areaStops().isEmpty() ) { - for (AreaStop areaStop : streetVertex.areaStops()) { + for (FeedScopedId id : targetVertex.areaStops()) { + AreaStop areaStop = Objects.requireNonNull(stopResolver.getAreaStop(id)); // This is for a simplification, so that we only return one vertex from each // stop location. All vertices are added to the multimap, which is filtered // below, so that only the closest vertex is added to stopsFound diff --git a/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java b/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java index d1af8491519..28a89fd0900 100644 --- a/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java +++ b/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java @@ -147,7 +147,9 @@ default GraphFinder graphFinder() { /* Sandbox modules */ @Nullable -<<<<<<< HEAD + CarpoolingService carpoolingService(); + + @Nullable default List listExtensionRequestContexts(RouteRequest request) { var list = new ArrayList(); if (OTPFeature.DataOverlay.isOn()) { @@ -159,18 +161,6 @@ default List listExtensionRequestContexts(RouteRequest ); } return list; -======= - CarpoolingService carpoolingService(); - - @Nullable - default DataOverlayContext dataOverlayContext(RouteRequest request) { - return OTPFeature.DataOverlay.isOnElseNull(() -> - new DataOverlayContext( - graph().dataOverlayParameterBindings, - request.preferences().system().dataOverlay() - ) - ); ->>>>>>> 5bec82e6a3 (feat: carpooling skeleton) } @Nullable diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java b/application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java new file mode 100644 index 00000000000..040e9aa5f61 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java @@ -0,0 +1,68 @@ +package org.opentripplanner.ext.carpooling; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +/** + * Factory for creating mock GraphPath objects for testing. + */ +public class MockGraphPathFactory { + + /** + * Creates a mock GraphPath with default 5-minute duration. + */ + public static GraphPath createMockGraphPath() { + return createMockGraphPath(Duration.ofMinutes(5)); + } + + /** + * Creates a mock GraphPath with specified duration. + */ + @SuppressWarnings("unchecked") + public static GraphPath createMockGraphPath(Duration duration) { + var mockPath = (GraphPath) mock(GraphPath.class); + + // Set public fields directly instead of stubbing to avoid Mockito state issues + mockPath.states = new java.util.LinkedList<>(createMockStates(duration)); + mockPath.edges = new java.util.LinkedList(); + + return mockPath; + } + + /** + * Creates mock State objects with specified time duration. + */ + private static List createMockStates(Duration duration) { + var startState = mock(State.class); + var endState = mock(State.class); + + var startTime = Instant.now(); + var endTime = startTime.plus(duration); + + when(startState.getTime()).thenReturn(startTime); + when(endState.getTime()).thenReturn(endTime); + + var mockVertex = mock(Vertex.class); + when(startState.getVertex()).thenReturn(mockVertex); + when(endState.getVertex()).thenReturn(mockVertex); + + return List.of(startState, endState); + } + + /** + * Creates multiple mock GraphPaths with varying durations. + */ + public static List> createMockGraphPaths(int count) { + return java.util.stream.IntStream.range(0, count) + .mapToObj(i -> createMockGraphPath(Duration.ofMinutes(5 + i))) + .toList(); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java new file mode 100644 index 00000000000..80cb2ceeef6 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java @@ -0,0 +1,133 @@ +package org.opentripplanner.ext.carpooling; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.opentripplanner.ext.carpooling.model.CarpoolStop; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.transit.model.site.AreaStop; + +/** + * Builder utility for creating test CarpoolTrip instances without requiring full Graph infrastructure. + */ +public class TestCarpoolTripBuilder { + + private static final AtomicInteger idCounter = new AtomicInteger(0); + private static final AtomicInteger areaStopCounter = new AtomicInteger(0); + + /** + * Creates a simple trip with no stops and default capacity of 4. + */ + public static CarpoolTrip createSimpleTrip(WgsCoordinate boarding, WgsCoordinate alighting) { + return createTripWithCapacity(4, boarding, List.of(), alighting); + } + + /** + * Creates a trip with specified stops. + */ + public static CarpoolTrip createTripWithStops( + WgsCoordinate boarding, + List stops, + WgsCoordinate alighting + ) { + return createTripWithCapacity(4, boarding, stops, alighting); + } + + /** + * Creates a trip with specified capacity. + */ + public static CarpoolTrip createTripWithCapacity( + int seats, + WgsCoordinate boarding, + List stops, + WgsCoordinate alighting + ) { + return createTripWithDeviationBudget(Duration.ofMinutes(10), seats, boarding, stops, alighting); + } + + /** + * Creates a trip with specified deviation budget. + */ + public static CarpoolTrip createTripWithDeviationBudget( + Duration deviationBudget, + WgsCoordinate boarding, + WgsCoordinate alighting + ) { + return createTripWithDeviationBudget(deviationBudget, 4, boarding, List.of(), alighting); + } + + /** + * Creates a trip with all parameters specified. + */ + public static CarpoolTrip createTripWithDeviationBudget( + Duration deviationBudget, + int seats, + WgsCoordinate boarding, + List stops, + WgsCoordinate alighting + ) { + return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder( + org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( + "TEST", + "trip-" + idCounter.incrementAndGet() + ) + ) + .withBoardingArea(createAreaStop(boarding)) + .withAlightingArea(createAreaStop(alighting)) + .withStops(stops) + .withAvailableSeats(seats) + .withStartTime(ZonedDateTime.now()) + .withDeviationBudget(deviationBudget) + .build(); + } + + /** + * Creates a CarpoolStop with specified sequence (0-based) and passenger delta. + */ + public static CarpoolStop createStop(int zeroBasedSequence, int passengerDelta) { + return createStopAt(zeroBasedSequence, passengerDelta, TestFixtures.OSLO_CENTER); + } + + /** + * Creates a CarpoolStop at a specific location. + */ + public static CarpoolStop createStopAt(int sequence, WgsCoordinate location) { + return createStopAt(sequence, 0, location); + } + + /** + * Creates a CarpoolStop with all parameters. + */ + public static CarpoolStop createStopAt(int sequence, int passengerDelta, WgsCoordinate location) { + return new CarpoolStop( + createAreaStop(location), + CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF, + passengerDelta, + sequence, + null + ); + } + + /** + * Creates a minimal AreaStop for testing. + */ + private static AreaStop createAreaStop(WgsCoordinate coordinate) { + // Create a simple point geometry at the coordinate + var geometryFactory = new org.locationtech.jts.geom.GeometryFactory(); + var point = geometryFactory.createPoint( + new org.locationtech.jts.geom.Coordinate(coordinate.longitude(), coordinate.latitude()) + ); + + return AreaStop.of( + org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( + "TEST", + "area-" + areaStopCounter.incrementAndGet() + ), + areaStopCounter::getAndIncrement + ) + .withGeometry(point) + .build(); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java b/application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java new file mode 100644 index 00000000000..0b57bb05f3f --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java @@ -0,0 +1,34 @@ +package org.opentripplanner.ext.carpooling; + +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Shared test coordinates and constants for carpooling tests. + * Uses Oslo area coordinates for realistic geographic testing. + */ +public class TestFixtures { + + // Base coordinates (Oslo area) + public static final WgsCoordinate OSLO_CENTER = new WgsCoordinate(59.9139, 10.7522); + public static final WgsCoordinate OSLO_EAST = new WgsCoordinate(59.9149, 10.7922); // ~2.5km east + public static final WgsCoordinate OSLO_NORTH = new WgsCoordinate(59.9439, 10.7522); // ~3.3km north + public static final WgsCoordinate OSLO_SOUTH = new WgsCoordinate(59.8839, 10.7522); // ~3.3km south + public static final WgsCoordinate OSLO_WEST = new WgsCoordinate(59.9139, 10.7122); // ~2.5km west + + // Coordinates for testing routes around obstacles (e.g., lake) + public static final WgsCoordinate LAKE_NORTH = new WgsCoordinate(59.9439, 10.7522); + public static final WgsCoordinate LAKE_EAST = new WgsCoordinate(59.9239, 10.7922); + public static final WgsCoordinate LAKE_SOUTH = new WgsCoordinate(59.9039, 10.7522); + public static final WgsCoordinate LAKE_WEST = new WgsCoordinate(59.9239, 10.7122); + + // Intermediate points for testing + public static final WgsCoordinate OSLO_MIDPOINT_NORTH = new WgsCoordinate(59.9289, 10.7522); + public static final WgsCoordinate OSLO_NORTHEAST = new WgsCoordinate(59.9439, 10.7922); + public static final WgsCoordinate OSLO_SOUTHEAST = new WgsCoordinate(59.8839, 10.7922); + public static final WgsCoordinate OSLO_SOUTHWEST = new WgsCoordinate(59.8839, 10.7122); + public static final WgsCoordinate OSLO_NORTHWEST = new WgsCoordinate(59.9439, 10.7122); + + private TestFixtures() { + // Utility class + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java new file mode 100644 index 00000000000..9f0af91cee0 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java @@ -0,0 +1,86 @@ +package org.opentripplanner.ext.carpooling.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CapacityFilterTest { + + private CapacityFilter filter; + + @BeforeEach + void setup() { + filter = new CapacityFilter(); + } + + @Test + void accepts_tripWithCapacity_returnsTrue() { + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); + + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void accepts_tripAtFullCapacity_returnsTrue() { + // CapacityFilter only checks configured capacity, not actual occupancy + // Detailed capacity validation happens in the validator layer + var stop1 = createStop(0, +4); // All 4 seats taken + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + // Filter accepts because trip has capacity configured (even if currently full) + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void accepts_tripWithOneOpenSeat_returnsTrue() { + var stop1 = createStop(0, +3); // 3 of 4 seats taken + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void accepts_zeroCapacityTrip_returnsFalse() { + var trip = createTripWithCapacity(0, OSLO_CENTER, List.of(), OSLO_NORTH); + + assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void accepts_passengerCoordinatesIgnored() { + // Filter only checks if ANY capacity exists, not position-specific + var trip = createTripWithCapacity(2, OSLO_CENTER, List.of(), OSLO_NORTH); + + // Should accept regardless of passenger coordinates + assertTrue(filter.accepts(trip, OSLO_SOUTH, OSLO_EAST)); + assertTrue(filter.accepts(trip, OSLO_NORTH, OSLO_SOUTH)); + } + + @Test + void accepts_tripWithFluctuatingCapacity_checksOverallAvailability() { + var stop1 = createStop(0, +2); // 2 passengers + var stop2 = createStop(1, -2); // Dropoff 2 + var stop3 = createStop(2, +1); // Pickup 1 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); + + // At some point there's capacity (positions 0, 2+) + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void accepts_tripAlwaysAtCapacity_returnsTrue() { + // CapacityFilter only checks configured capacity, not actual occupancy + var stop1 = createStop(0, +4); // Fill to capacity + var stop2 = createStop(1, -1); // Drop 1 + var stop3 = createStop(2, +1); // Pick 1 (back to full) + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); + + // Filter accepts because trip has capacity configured + // The validator will determine if there's actual room for insertion + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java new file mode 100644 index 00000000000..c515481129a --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java @@ -0,0 +1,135 @@ +package org.opentripplanner.ext.carpooling.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +class DirectionalCompatibilityFilterTest { + + private DirectionalCompatibilityFilter filter; + + @BeforeEach + void setup() { + filter = new DirectionalCompatibilityFilter(); + } + + @Test + void accepts_passengerAlignedWithTrip_returnsTrue() { + var trip = createSimpleTrip( + OSLO_CENTER, + OSLO_NORTH // Trip goes north + ); + + // Passenger also going north + var passengerPickup = OSLO_EAST; + var passengerDropoff = new WgsCoordinate(59.9549, 10.7922); // Northeast + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerOppositeDirection_returnsFalse() { + var trip = createSimpleTrip( + OSLO_CENTER, + OSLO_NORTH // Trip goes north + ); + + // Passenger going south + var passengerPickup = OSLO_EAST; + var passengerDropoff = OSLO_CENTER; + + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_tripAroundLake_passengerOnSegment_returnsTrue() { + // Trip goes around a lake: North → East → South → West + var stop1 = createStopAt(0, LAKE_EAST); + var stop2 = createStopAt(1, LAKE_SOUTH); + var trip = createTripWithStops(LAKE_NORTH, List.of(stop1, stop2), LAKE_WEST); + + // Passenger aligned with the southward segment (East → South) + var passengerPickup = new WgsCoordinate(59.9339, 10.7922); // East side + var passengerDropoff = new WgsCoordinate(59.9139, 10.7922); // South of east + + // Should accept because passenger aligns with East→South segment + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerOutsideCorridor_returnsFalse() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger far to the east, outside route corridor + var passengerPickup = new WgsCoordinate(59.9139, 11.0000); // Way east + var passengerDropoff = new WgsCoordinate(59.9439, 11.0000); + + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerPartiallyAligned_withinTolerance_returnsTrue() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + + // Passenger going northeast (~45° off) + var passengerPickup = OSLO_CENTER; + var passengerDropoff = OSLO_NORTHEAST; + + // Should accept within default tolerance (60°) + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerPerpendicularToTrip_returnsFalse() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + + // Passenger going east (90° perpendicular) + var passengerPickup = OSLO_CENTER; + var passengerDropoff = OSLO_EAST; + + // Should reject (exceeds 60° tolerance) + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_complexRoute_multipleSegments_findsCompatibleSegment() { + // Trip with multiple segments going different directions + var stop1 = createStopAt(0, OSLO_EAST); // Go east first + var stop2 = createStopAt(1, OSLO_NORTHEAST); // Then northeast + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + // Passenger going northeast (aligns with second segment) + var passengerPickup = new WgsCoordinate(59.9289, 10.7722); + var passengerDropoff = new WgsCoordinate(59.9389, 10.7822); + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_tripWithSingleStop_checksAllSegments() { + var stop1 = createStopAt(0, OSLO_EAST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + // Passenger aligned with first segment (Center → East) + var passengerPickup = new WgsCoordinate(59.9139, 10.7622); + var passengerDropoff = new WgsCoordinate(59.9139, 10.7822); + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerWithinCorridorButWrongDirection_returnsFalse() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + + // Passenger nearby but going opposite direction + var passengerPickup = new WgsCoordinate(59.9239, 10.7522); // North + var passengerDropoff = new WgsCoordinate(59.9139, 10.7522); // South (backtracking) + + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java new file mode 100644 index 00000000000..7f926cca557 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java @@ -0,0 +1,121 @@ +package org.opentripplanner.ext.carpooling.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class FilterChainTest { + + @Test + void accepts_allFiltersAccept_returnsTrue() { + var filter1 = mock(TripFilter.class); + var filter2 = mock(TripFilter.class); + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + when(filter1.accepts(any(), any(), any())).thenReturn(true); + when(filter2.accepts(any(), any(), any())).thenReturn(true); + + var chain = new FilterChain(List.of(filter1, filter2)); + + assertTrue(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); + verify(filter1).accepts(trip, OSLO_EAST, OSLO_WEST); + verify(filter2).accepts(trip, OSLO_EAST, OSLO_WEST); + } + + @Test + void accepts_oneFilterRejects_returnsFalse() { + var filter1 = mock(TripFilter.class); + var filter2 = mock(TripFilter.class); + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + when(filter1.accepts(any(), any(), any())).thenReturn(true); + when(filter2.accepts(any(), any(), any())).thenReturn(false); // Rejects + + var chain = new FilterChain(List.of(filter1, filter2)); + + assertFalse(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void accepts_shortCircuits_afterFirstRejection() { + var filter1 = mock(TripFilter.class); + var filter2 = mock(TripFilter.class); + var filter3 = mock(TripFilter.class); + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + when(filter1.accepts(any(), any(), any())).thenReturn(true); + when(filter2.accepts(any(), any(), any())).thenReturn(false); // Rejects + // filter3 should not be called + + var chain = new FilterChain(List.of(filter1, filter2, filter3)); + chain.accepts(trip, OSLO_EAST, OSLO_WEST); + + verify(filter1).accepts(any(), any(), any()); + verify(filter2).accepts(any(), any(), any()); + verify(filter3, never()).accepts(any(), any(), any()); // Not called + } + + @Test + void accepts_firstFilterRejects_doesNotCallOthers() { + var filter1 = mock(TripFilter.class); + var filter2 = mock(TripFilter.class); + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + when(filter1.accepts(any(), any(), any())).thenReturn(false); // First rejects + + var chain = new FilterChain(List.of(filter1, filter2)); + chain.accepts(trip, OSLO_EAST, OSLO_WEST); + + verify(filter1).accepts(any(), any(), any()); + verify(filter2, never()).accepts(any(), any(), any()); + } + + @Test + void standard_includesAllStandardFilters() { + var chain = FilterChain.standard(); + + // Should contain CapacityFilter and DirectionalCompatibilityFilter + // Verify by testing behavior with a trip that has no capacity + var emptyTrip = createTripWithCapacity(0, OSLO_CENTER, List.of(), OSLO_NORTH); + + // Should reject due to capacity filter + assertFalse(chain.accepts(emptyTrip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void standard_checksDirectionalCompatibility() { + var chain = FilterChain.standard(); + + // Trip going north, passenger going south + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Should reject due to directional filter + assertFalse(chain.accepts(trip, OSLO_EAST, OSLO_CENTER)); + } + + @Test + void emptyChain_acceptsAll() { + var chain = new FilterChain(List.of()); + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Empty chain accepts everything + assertTrue(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); + } + + @Test + void singleFilter_behavesCorrectly() { + var filter = mock(TripFilter.class); + when(filter.accepts(any(), any(), any())).thenReturn(true); + + var chain = new FilterChain(List.of(filter)); + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + assertTrue(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); + verify(filter).accepts(trip, OSLO_EAST, OSLO_WEST); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java new file mode 100644 index 00000000000..9de51944fbb --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java @@ -0,0 +1,229 @@ +package org.opentripplanner.ext.carpooling.routing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class InsertionCandidateTest { + + @Test + void additionalDuration_calculatesCorrectly() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(3); // 3 segments + + var candidate = new InsertionCandidate( + trip, + 1, // pickup position + 2, // dropoff position + segments, + Duration.ofMinutes(10), // baseline + Duration.ofMinutes(15) // total + ); + + assertEquals(Duration.ofMinutes(5), candidate.additionalDuration()); + } + + @Test + void additionalDuration_zeroAdditional_returnsZero() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(2); + + var candidate = new InsertionCandidate( + trip, + 1, + 2, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(10) // Same as baseline + ); + + assertEquals(Duration.ZERO, candidate.additionalDuration()); + } + + @Test + void isWithinDeviationBudget_withinBudget_returnsTrue() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(10), OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(2); + + var candidate = new InsertionCandidate( + trip, + 1, + 2, + segments, + Duration.ofMinutes(10), // baseline + Duration.ofMinutes(18) // total (8 min additional, within 10 min budget) + ); + + assertTrue(candidate.isWithinDeviationBudget()); + } + + @Test + void isWithinDeviationBudget_exceedsBudget_returnsFalse() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(2); + + var candidate = new InsertionCandidate( + trip, + 1, + 2, + segments, + Duration.ofMinutes(10), // baseline + Duration.ofMinutes(20) // total (10 min additional, exceeds 5 min budget) + ); + + assertFalse(candidate.isWithinDeviationBudget()); + } + + @Test + void isWithinDeviationBudget_exactlyAtBudget_returnsTrue() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(2); + + var candidate = new InsertionCandidate( + trip, + 1, + 2, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) // Exactly 5 min additional + ); + + assertTrue(candidate.isWithinDeviationBudget()); + } + + @Test + void getPickupSegments_returnsCorrectRange() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(5); + + var candidate = new InsertionCandidate( + trip, + 2, + 4, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var pickupSegments = candidate.getPickupSegments(); + assertEquals(2, pickupSegments.size()); // Segments 0-1 (before position 2) + assertEquals(segments.subList(0, 2), pickupSegments); + } + + @Test + void getPickupSegments_positionZero_returnsEmpty() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(3); + + var candidate = new InsertionCandidate( + trip, + 0, + 2, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var pickupSegments = candidate.getPickupSegments(); + assertTrue(pickupSegments.isEmpty()); + } + + @Test + void getSharedSegments_returnsCorrectRange() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(5); + + var candidate = new InsertionCandidate( + trip, + 1, + 3, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var sharedSegments = candidate.getSharedSegments(); + assertEquals(2, sharedSegments.size()); // Segments 1-2 (positions 1 to 3) + assertEquals(segments.subList(1, 3), sharedSegments); + } + + @Test + void getSharedSegments_adjacentPositions_returnsSingleSegment() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(3); + + var candidate = new InsertionCandidate( + trip, + 1, + 2, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var sharedSegments = candidate.getSharedSegments(); + assertEquals(1, sharedSegments.size()); + } + + @Test + void getDropoffSegments_returnsCorrectRange() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(5); + + var candidate = new InsertionCandidate( + trip, + 1, + 3, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var dropoffSegments = candidate.getDropoffSegments(); + assertEquals(2, dropoffSegments.size()); // Segments 3-4 (after position 3) + assertEquals(segments.subList(3, 5), dropoffSegments); + } + + @Test + void getDropoffSegments_atEnd_returnsEmpty() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(3); + + var candidate = new InsertionCandidate( + trip, + 1, + 3, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var dropoffSegments = candidate.getDropoffSegments(); + assertTrue(dropoffSegments.isEmpty()); + } + + @Test + void toString_includesKeyInformation() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var segments = createMockGraphPaths(3); + + var candidate = new InsertionCandidate( + trip, + 1, + 2, + segments, + Duration.ofMinutes(10), + Duration.ofMinutes(15) + ); + + var str = candidate.toString(); + assertTrue(str.contains("pickup@1")); + assertTrue(str.contains("dropoff@2")); + assertTrue(str.contains("300s")); // 5 min = 300s additional + assertTrue(str.contains("segments=3")); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java new file mode 100644 index 00000000000..2650395a0f2 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java @@ -0,0 +1,204 @@ +package org.opentripplanner.ext.carpooling.routing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; +import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.routing.OptimalInsertionStrategy.RoutingFunction; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationResult; + +class OptimalInsertionStrategyTest { + + private InsertionValidator mockValidator; + private RoutingFunction mockRoutingFunction; + private OptimalInsertionStrategy strategy; + + @BeforeEach + void setup() { + mockValidator = mock(InsertionValidator.class); + mockRoutingFunction = mock(RoutingFunction.class); + strategy = new OptimalInsertionStrategy(mockValidator, mockRoutingFunction); + } + + @Test + void findOptimalInsertion_noValidPositions_returnsNull() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Validator rejects all positions + when(mockValidator.validate(any())).thenReturn(ValidationResult.invalid("Test reject")); + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNull(result); + } + + @Test + void findOptimalInsertion_oneValidPosition_returnsCandidate() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(); + + // Accept one specific position (null-safe matcher) + when( + mockValidator.validate( + argThat(ctx -> ctx != null && ctx.pickupPosition() == 1 && ctx.dropoffPosition() == 2) + ) + ).thenReturn(ValidationResult.valid()); + + when( + mockValidator.validate( + argThat(ctx -> ctx == null || ctx.pickupPosition() != 1 || ctx.dropoffPosition() != 2) + ) + ).thenReturn(ValidationResult.invalid("Wrong position")); + + // Mock routing to return valid paths + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + assertEquals(1, result.pickupPosition()); + assertEquals(2, result.dropoffPosition()); + } + + @Test + void findOptimalInsertion_routingFails_skipsPosition() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + + when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); + + // Routing sequence: + // 1. Baseline calculation (1 segment: OSLO_CENTER → OSLO_NORTH) = mockPath + // 2. First insertion attempt fails (null, null, null for 3 segments) + // 3. Second insertion attempt succeeds (mockPath for all 3 segments) + when(mockRoutingFunction.route(any(), any())) + .thenReturn(mockPath) // Baseline + .thenReturn(null) // First insertion - segment 1 fails + .thenReturn(mockPath) // Second insertion - all segments succeed + .thenReturn(mockPath) + .thenReturn(mockPath); + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + // Should skip failed routing and find a valid one + assertNotNull(result); + } + + @Test + void findOptimalInsertion_exceedsDeviationBudget_returnsNull() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + // Create routing that results in excessive additional time + // Baseline is 2 segments * 5 min = 10 min + // Modified route is 3 segments * 20 min = 60 min + // Additional = 50 min, exceeds 5 min budget + var mockPath = createMockGraphPath(Duration.ofMinutes(20)); + + when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + // Should not return candidate that exceeds budget + assertNull(result); + } + + @Test + void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { + var stop1 = createStopAt(0, OSLO_EAST); + var stop2 = createStopAt(1, OSLO_WEST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(); + + when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = strategy.findOptimalInsertion(trip, OSLO_SOUTH, OSLO_EAST); + + // Should have evaluated multiple positions + // Verify validator was called multiple times + verify(mockValidator, atLeast(3)).validate(any()); + } + + @Test + void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); + + // Routing returns null (failure) for baseline calculation + when(mockRoutingFunction.route(any(), any())).thenReturn(null); + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNull(result); + } + + @Test + void findOptimalInsertion_selectsMinimumAdditionalDuration() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(20), OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + // Baseline: 1 segment (CENTER → NORTH) at 10 min + // The algorithm will try multiple pickup/dropoff positions + // We'll use Answer to return different durations based on segment index + var mockPath10 = createMockGraphPath(Duration.ofMinutes(10)); + var mockPath4 = createMockGraphPath(Duration.ofMinutes(4)); + var mockPath6 = createMockGraphPath(Duration.ofMinutes(6)); + var mockPath5 = createMockGraphPath(Duration.ofMinutes(5)); + var mockPath7 = createMockGraphPath(Duration.ofMinutes(7)); + + when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); + + // Use thenAnswer to provide consistent route times + // Just return paths with reasonable durations for all calls + when(mockRoutingFunction.route(any(), any())) + .thenReturn(mockPath10) // Baseline + .thenReturn(mockPath4, mockPath5, mockPath6) // First insertion (15 min total, 5 min additional) + .thenReturn(mockPath5, mockPath6, mockPath7); // Second insertion (18 min total, 8 min additional) + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + // Should have selected one of the evaluated insertions + // The exact additional duration depends on which position was evaluated first + assertTrue(result.additionalDuration().compareTo(Duration.ofMinutes(20)) <= 0); + assertTrue(result.additionalDuration().compareTo(Duration.ZERO) > 0); + } + + @Test + void findOptimalInsertion_simpleTrip_hasExpectedStructure() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(); + + when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + assertNotNull(result.trip()); + assertNotNull(result.routeSegments()); + assertFalse(result.routeSegments().isEmpty()); + assertTrue(result.pickupPosition() >= 0); + assertTrue(result.dropoffPosition() > result.pickupPosition()); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java new file mode 100644 index 00000000000..d552d370b53 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java @@ -0,0 +1,79 @@ +package org.opentripplanner.ext.carpooling.routing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import org.junit.jupiter.api.Test; + +class RoutePointTest { + + @Test + void constructor_validInputs_createsInstance() { + var point = new RoutePoint(OSLO_CENTER, "Test Point"); + + assertEquals(OSLO_CENTER, point.coordinate()); + assertEquals("Test Point", point.label()); + } + + @Test + void constructor_nullCoordinate_throwsException() { + assertThrows(IllegalArgumentException.class, () -> new RoutePoint(null, "Test")); + } + + @Test + void constructor_nullLabel_throwsException() { + assertThrows(IllegalArgumentException.class, () -> new RoutePoint(OSLO_CENTER, null)); + } + + @Test + void constructor_blankLabel_throwsException() { + assertThrows(IllegalArgumentException.class, () -> new RoutePoint(OSLO_CENTER, " ")); + } + + @Test + void constructor_emptyLabel_throwsException() { + assertThrows(IllegalArgumentException.class, () -> new RoutePoint(OSLO_CENTER, "")); + } + + @Test + void toString_includesLabelAndCoordinates() { + var point = new RoutePoint(OSLO_CENTER, "Oslo"); + var str = point.toString(); + + assertTrue(str.contains("Oslo")); + assertTrue(str.contains("59.91")); // Partial coordinate + assertTrue(str.contains("10.75")); + } + + @Test + void equals_sameValues_returnsTrue() { + var point1 = new RoutePoint(OSLO_CENTER, "Test"); + var point2 = new RoutePoint(OSLO_CENTER, "Test"); + + assertEquals(point1, point2); + } + + @Test + void equals_differentCoordinates_returnsFalse() { + var point1 = new RoutePoint(OSLO_CENTER, "Test"); + var point2 = new RoutePoint(OSLO_NORTH, "Test"); + + assertNotEquals(point1, point2); + } + + @Test + void equals_differentLabels_returnsFalse() { + var point1 = new RoutePoint(OSLO_CENTER, "Test1"); + var point2 = new RoutePoint(OSLO_CENTER, "Test2"); + + assertNotEquals(point1, point2); + } + + @Test + void hashCode_sameValues_returnsSameHash() { + var point1 = new RoutePoint(OSLO_CENTER, "Test"); + var point2 = new RoutePoint(OSLO_CENTER, "Test"); + + assertEquals(point1.hashCode(), point2.hashCode()); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java new file mode 100644 index 00000000000..7d57d34d991 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java @@ -0,0 +1,225 @@ +package org.opentripplanner.ext.carpooling.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.util.DirectionalCalculator.DirectionalAlignment; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +class DirectionalCalculatorTest { + + private static final double TOLERANCE = 5.0; // 5 degree tolerance for cardinal directions + + @Test + void calculateBearing_northward_returns0Degrees() { + // Oslo center to Oslo north should be ~0° (north) + double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_NORTH); + assertEquals(0.0, bearing, TOLERANCE); + } + + @Test + void calculateBearing_eastward_returns90Degrees() { + // Oslo center to Oslo east should be ~90° (east) + double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_EAST); + assertEquals(90.0, bearing, TOLERANCE); + } + + @Test + void calculateBearing_southward_returns180Degrees() { + double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_SOUTH); + assertEquals(180.0, bearing, TOLERANCE); + } + + @Test + void calculateBearing_westward_returns270Degrees() { + double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_WEST); + assertEquals(270.0, bearing, TOLERANCE); + } + + @Test + void bearingDifference_similarDirections_returnsSmallValue() { + // 10° and 20° should be 10° apart + double diff = DirectionalCalculator.bearingDifference(10.0, 20.0); + assertEquals(10.0, diff, 0.01); + } + + @Test + void bearingDifference_oppositeDirections_returns180() { + // North (0°) and South (180°) are 180° apart + double diff = DirectionalCalculator.bearingDifference(0.0, 180.0); + assertEquals(180.0, diff, 0.01); + } + + @Test + void bearingDifference_wrapAround_returnsShortestAngle() { + // 10° and 350° are only 20° apart (not 340°) + double diff = DirectionalCalculator.bearingDifference(10.0, 350.0); + assertEquals(20.0, diff, 0.01); + } + + @Test + void bearingDifference_reverse_returnsShortestAngle() { + // Should be symmetric + double diff1 = DirectionalCalculator.bearingDifference(10.0, 350.0); + double diff2 = DirectionalCalculator.bearingDifference(350.0, 10.0); + assertEquals(diff1, diff2, 0.01); + } + + @Test + void isDirectionallyCompatible_sameDirection_returnsTrue() { + // Both going north + boolean compatible = DirectionalCalculator.isDirectionallyCompatible( + OSLO_CENTER, + OSLO_NORTH, // Trip: north + OSLO_EAST, + new WgsCoordinate(59.9549, 10.7922), // Passenger: also north + 60.0 + ); + assertTrue(compatible); + } + + @Test + void isDirectionallyCompatible_oppositeDirection_returnsFalse() { + // Trip north, passenger south + boolean compatible = DirectionalCalculator.isDirectionallyCompatible( + OSLO_CENTER, + OSLO_NORTH, // Trip: north + OSLO_EAST, + OSLO_CENTER, // Passenger: west/southwest + 60.0 + ); + assertFalse(compatible); + } + + @Test + void isDirectionallyCompatible_withinTolerance_returnsTrue() { + // Trip going north, passenger going slightly northeast (within 60° tolerance) + boolean compatible = DirectionalCalculator.isDirectionallyCompatible( + OSLO_CENTER, + OSLO_NORTH, + OSLO_CENTER, + OSLO_NORTHEAST, // Northeast, ~45° from north + 60.0 + ); + assertTrue(compatible); + } + + @Test + void isDirectionallyCompatible_exceedsTolerance_returnsFalse() { + // Trip going north, passenger going east (90° difference) + boolean compatible = DirectionalCalculator.isDirectionallyCompatible( + OSLO_CENTER, + OSLO_NORTH, + OSLO_CENTER, + OSLO_EAST, // East, 90° from north + 60.0 // Tolerance too small + ); + assertFalse(compatible); + } + + @Test + void maintainsForwardProgress_straightLine_returnsTrue() { + // Inserting point along straight line maintains progress + boolean maintains = DirectionalCalculator.maintainsForwardProgress( + OSLO_CENTER, + OSLO_MIDPOINT_NORTH, // Midpoint north + OSLO_NORTH, + 90.0 + ); + assertTrue(maintains); + } + + @Test + void maintainsForwardProgress_backtracking_returnsFalse() { + // Inserting point behind causes backtracking + boolean maintains = DirectionalCalculator.maintainsForwardProgress( + OSLO_CENTER, + OSLO_SOUTH, // Point south when going north + OSLO_NORTH, + 90.0 + ); + assertFalse(maintains); + } + + @Test + void maintainsForwardProgress_moderateDetour_returnsTrue() { + // Slight eastward detour should be allowed + var slightlyEast = new WgsCoordinate(59.9289, 10.7622); + boolean maintains = DirectionalCalculator.maintainsForwardProgress( + OSLO_CENTER, + slightlyEast, + OSLO_NORTH, + 90.0 + ); + assertTrue(maintains); + } + + @Test + void maintainsForwardProgress_largeDetour_returnsFalse() { + // Large detour exceeds tolerance + boolean maintains = DirectionalCalculator.maintainsForwardProgress( + OSLO_CENTER, + OSLO_WEST, // Large westward detour when going north + OSLO_NORTH, + 45.0 // Strict tolerance + ); + assertFalse(maintains); + } + + @Test + void classify_highlyAligned_when10Degrees() { + // Very close directions + var alignment = DirectionalCalculator.classify( + OSLO_CENTER, + OSLO_NORTH, + OSLO_EAST, + new WgsCoordinate(59.9549, 10.7922) // Slightly north from east point + ); + assertEquals(DirectionalAlignment.HIGHLY_ALIGNED, alignment); + } + + @Test + void classify_aligned_when45Degrees() { + var alignment = DirectionalCalculator.classify( + OSLO_CENTER, + OSLO_NORTH, // North + OSLO_CENTER, + OSLO_NORTHEAST // Northeast (~45°) + ); + assertEquals(DirectionalAlignment.ALIGNED, alignment); + } + + @Test + void classify_divergent_when90Degrees() { + var alignment = DirectionalCalculator.classify( + OSLO_CENTER, + OSLO_NORTH, // North + OSLO_CENTER, + OSLO_EAST // East (90°) + ); + assertEquals(DirectionalAlignment.DIVERGENT, alignment); + } + + @Test + void classify_opposite_when180Degrees() { + var alignment = DirectionalCalculator.classify( + OSLO_CENTER, + OSLO_NORTH, // North + OSLO_CENTER, + OSLO_SOUTH // South (180°) + ); + assertEquals(DirectionalAlignment.OPPOSITE, alignment); + } + + @Test + void classify_withCustomThresholds_usesProvidedValues() { + var alignment = DirectionalCalculator.classify( + 45.0, // Bearing difference + 20.0, // Highly aligned threshold + 50.0, // Aligned threshold + 100.0 // Divergent threshold + ); + assertEquals(DirectionalAlignment.ALIGNED, alignment); // 45° fits in 20-50 range + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java new file mode 100644 index 00000000000..c802928cd49 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java @@ -0,0 +1,133 @@ +package org.opentripplanner.ext.carpooling.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class PassengerCountTimelineTest { + + @Test + void build_noStops_allZeros() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // No stops = no passengers along route + assertEquals(0, timeline.getPassengerCount(0)); + } + + @Test + void build_onePickupStop_incrementsAtStop() { + var stop1 = createStop(0, +1); // Pickup 1 passenger + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + assertEquals(0, timeline.getPassengerCount(0)); // Before stop + assertEquals(1, timeline.getPassengerCount(1)); // After stop + } + + @Test + void build_pickupAndDropoff_incrementsThenDecrements() { + var stop1 = createStop(0, +2); // Pickup 2 passengers + var stop2 = createStop(1, -1); // Dropoff 1 passenger + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + assertEquals(0, timeline.getPassengerCount(0)); // Before any stops + assertEquals(2, timeline.getPassengerCount(1)); // After first pickup + assertEquals(1, timeline.getPassengerCount(2)); // After dropoff + } + + @Test + void build_multipleStops_cumulativeCount() { + var stop1 = createStop(0, +1); + var stop2 = createStop(1, +2); + var stop3 = createStop(2, -1); + var stop4 = createStop(3, +1); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3, stop4), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + assertEquals(0, timeline.getPassengerCount(0)); + assertEquals(1, timeline.getPassengerCount(1)); // 0 + 1 + assertEquals(3, timeline.getPassengerCount(2)); // 1 + 2 + assertEquals(2, timeline.getPassengerCount(3)); // 3 - 1 + assertEquals(3, timeline.getPassengerCount(4)); // 2 + 1 + } + + @Test + void build_negativePassengerDelta_handlesDropoffs() { + var stop1 = createStop(0, +3); // Pickup 3 + var stop2 = createStop(1, -3); // Dropoff all 3 + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + assertEquals(0, timeline.getPassengerCount(0)); + assertEquals(3, timeline.getPassengerCount(1)); + assertEquals(0, timeline.getPassengerCount(2)); // Back to zero + } + + @Test + void hasCapacityInRange_noPassengers_hasCapacity() { + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + assertTrue(timeline.hasCapacityInRange(0, 1, 1)); + assertTrue(timeline.hasCapacityInRange(0, 1, 4)); // Can fit all 4 seats + } + + @Test + void hasCapacityInRange_fullCapacity_noCapacity() { + var stop1 = createStop(0, +4); // Fill all 4 seats + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // No room for additional passenger after stop 1 + assertFalse(timeline.hasCapacityInRange(1, 2, 1)); + } + + @Test + void hasCapacityInRange_partialCapacity_hasCapacityForOne() { + var stop1 = createStop(0, +3); // 3 of 4 seats taken + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + assertTrue(timeline.hasCapacityInRange(1, 2, 1)); // Room for 1 + assertFalse(timeline.hasCapacityInRange(1, 2, 2)); // No room for 2 + } + + @Test + void hasCapacityInRange_acrossMultiplePositions_checksAll() { + var stop1 = createStop(0, +2); + var stop2 = createStop(1, +1); // Total 3 passengers + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Range 1-3 includes position with 3 passengers, so only 1 seat available + assertTrue(timeline.hasCapacityInRange(1, 3, 1)); + assertFalse(timeline.hasCapacityInRange(1, 3, 2)); + } + + @Test + void hasCapacityInRange_rangeBeforeStop_usesInitialCapacity() { + var stop1 = createStop(0, +4); // Fill capacity at position 1 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Before stop, should have full capacity + assertTrue(timeline.hasCapacityInRange(0, 1, 4)); + } + + @Test + void hasCapacityInRange_capacityFreesUpInRange_checksMaxInRange() { + var stop1 = createStop(0, +3); // 3 passengers + var stop2 = createStop(1, -2); // 2 dropoff, leaving 1 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Range includes both positions - max passengers is 3 (at position 1) + assertTrue(timeline.hasCapacityInRange(1, 3, 1)); // 4 total - 3 max = 1 available + assertFalse(timeline.hasCapacityInRange(1, 3, 2)); // Not enough + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java new file mode 100644 index 00000000000..4385e9751ae --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java @@ -0,0 +1,99 @@ +package org.opentripplanner.ext.carpooling.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +class RouteGeometryTest { + + @Test + void calculateBoundingBox_singlePoint_returnsPointBox() { + List route = List.of(OSLO_CENTER); + var bbox = RouteGeometry.calculateBoundingBox(route); + + assertEquals(OSLO_CENTER.latitude(), bbox.minLat()); + assertEquals(OSLO_CENTER.latitude(), bbox.maxLat()); + assertEquals(OSLO_CENTER.longitude(), bbox.minLon()); + assertEquals(OSLO_CENTER.longitude(), bbox.maxLon()); + } + + @Test + void calculateBoundingBox_twoPoints_returnsEnclosingBox() { + List route = List.of(OSLO_CENTER, OSLO_NORTH); + var bbox = RouteGeometry.calculateBoundingBox(route); + + assertEquals(OSLO_CENTER.latitude(), bbox.minLat()); + assertEquals(OSLO_NORTH.latitude(), bbox.maxLat()); + assertEquals(OSLO_CENTER.longitude(), bbox.minLon()); + assertEquals(OSLO_CENTER.longitude(), bbox.maxLon()); + } + + @Test + void calculateBoundingBox_multiplePoints_findsMinMax() { + List route = List.of(OSLO_CENTER, OSLO_NORTH, OSLO_EAST, OSLO_SOUTH, OSLO_WEST); + var bbox = RouteGeometry.calculateBoundingBox(route); + + assertEquals(OSLO_SOUTH.latitude(), bbox.minLat()); + assertEquals(OSLO_NORTH.latitude(), bbox.maxLat()); + assertEquals(OSLO_WEST.longitude(), bbox.minLon()); + assertEquals(OSLO_EAST.longitude(), bbox.maxLon()); + } + + @Test + void calculateBoundingBox_emptyList_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + RouteGeometry.calculateBoundingBox(List.of()) + ); + } + + @Test + void areBothWithinCorridor_straightRoute_bothClose_returnsTrue() { + List route = List.of(OSLO_CENTER, OSLO_NORTH); + var pickup = new WgsCoordinate(59.9189, 10.7522); // Slightly north of center + var dropoff = new WgsCoordinate(59.9389, 10.7522); // Slightly south of north + + assertTrue(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); + } + + @Test + void areBothWithinCorridor_straightRoute_oneFar_returnsFalse() { + List route = List.of(OSLO_CENTER, OSLO_NORTH); + var pickup = new WgsCoordinate(59.9189, 10.7522); // Close + var dropoff = new WgsCoordinate(59.9189, 11.0000); // Far east + + assertFalse(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); + } + + @Test + void areBothWithinCorridor_bothOutside_returnsFalse() { + List route = List.of(OSLO_CENTER, OSLO_NORTH); + var pickup = new WgsCoordinate(59.9139, 11.0000); // Far east + var dropoff = new WgsCoordinate(59.9439, 11.0000); // Far east + + assertFalse(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); + } + + @Test + void areBothWithinCorridor_emptyRoute_returnsFalse() { + // Empty route should return false (or throw exception, both are acceptable) + try { + assertFalse(RouteGeometry.areBothWithinCorridor(List.of(), OSLO_CENTER, OSLO_NORTH)); + } catch (IllegalArgumentException e) { + // Also acceptable to throw exception for empty route + assertTrue(e.getMessage().contains("empty")); + } + } + + @Test + void areBothWithinCorridor_singlePointRoute_usesExpansion() { + List route = List.of(OSLO_CENTER); + // Points within expanded bounding box should return true + var pickup = new WgsCoordinate(59.9140, 10.7523); // Very close + var dropoff = new WgsCoordinate(59.9141, 10.7524); + + assertTrue(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java new file mode 100644 index 00000000000..db2b126ad58 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java @@ -0,0 +1,120 @@ +package org.opentripplanner.ext.carpooling.validation; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationContext; + +class CapacityValidatorTest { + + private CapacityValidator validator; + + @BeforeEach + void setup() { + validator = new CapacityValidator(); + } + + @Test + void validate_sufficientCapacity_returnsValid() { + var stop1 = createStop(0, +2); // 2 passengers + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + + var context = new ValidationContext( + 1, + 2, // Pickup at 1, dropoff at 2 + OSLO_EAST, + OSLO_WEST, + routeCoords, + timeline + ); + + var result = validator.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_insufficientCapacityAtPickup_returnsInvalid() { + var stop1 = createStop(0, +4); // All 4 seats taken + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + + var context = new ValidationContext( + 2, + 3, // Try to insert after stop (no capacity) + OSLO_EAST, + OSLO_WEST, + routeCoords, + timeline + ); + + var result = validator.validate(context); + assertFalse(result.isValid()); + assertTrue(result.reason().contains("capacity")); + } + + @Test + void validate_capacityFreedAtDropoff_checksRange() { + var stop1 = createStop(0, +3); // 3 passengers + var stop2 = createStop(1, -2); // 2 dropoff, leaving 1 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_SOUTH, OSLO_NORTH); + + // Inserting between stop1 and stop2 (3 passengers) - only 1 seat free + var context = new ValidationContext(2, 3, OSLO_EAST, OSLO_WEST, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); // 1 seat available + } + + @Test + void validate_noCapacityInRange_returnsInvalid() { + var stop1 = createStop(0, +4); // Fill all capacity + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + + // Try to insert after the stop where capacity is full + var context = new ValidationContext(2, 3, OSLO_EAST, OSLO_WEST, routeCoords, timeline); + + var result = validator.validate(context); + assertFalse(result.isValid()); + } + + @Test + void validate_capacityAtBeginning_beforeAnyStops_returnsValid() { + var stop1 = createStop(0, +3); + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + + // Insert before any stops (full capacity available) + var context = new ValidationContext(1, 2, OSLO_EAST, OSLO_WEST, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_exactlyAtCapacity_returnsInvalid() { + var stop1 = createStop(0, +3); // 3 passengers, leaving 1 seat + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + + // This would require 2 additional seats (passenger + existing 3 = 5) + // But we can only test for 1 additional passenger, so let's test the boundary + var context = new ValidationContext(2, 3, OSLO_EAST, OSLO_WEST, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); // Should have exactly 1 seat available + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java new file mode 100644 index 00000000000..1caff967ded --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java @@ -0,0 +1,157 @@ +package org.opentripplanner.ext.carpooling.validation; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationContext; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationResult; + +class CompositeValidatorTest { + + @Test + void validate_allValidatorsPass_returnsValid() { + var validator1 = mock(InsertionValidator.class); + var validator2 = mock(InsertionValidator.class); + + when(validator1.validate(any())).thenReturn(ValidationResult.valid()); + when(validator2.validate(any())).thenReturn(ValidationResult.valid()); + + var composite = new CompositeValidator(List.of(validator1, validator2)); + var context = createDummyContext(); + + var result = composite.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_oneValidatorFails_returnsInvalid() { + var validator1 = mock(InsertionValidator.class); + var validator2 = mock(InsertionValidator.class); + + when(validator1.validate(any())).thenReturn(ValidationResult.valid()); + when(validator2.validate(any())).thenReturn(ValidationResult.invalid("Test failure")); + + var composite = new CompositeValidator(List.of(validator1, validator2)); + var context = createDummyContext(); + + var result = composite.validate(context); + assertFalse(result.isValid()); + assertEquals("Test failure", result.reason()); + } + + @Test + void validate_shortCircuits_afterFirstFailure() { + var validator1 = mock(InsertionValidator.class); + var validator2 = mock(InsertionValidator.class); + var validator3 = mock(InsertionValidator.class); + + when(validator1.validate(any())).thenReturn(ValidationResult.valid()); + when(validator2.validate(any())).thenReturn(ValidationResult.invalid("Fail")); + + var composite = new CompositeValidator(List.of(validator1, validator2, validator3)); + var context = createDummyContext(); + + composite.validate(context); + + verify(validator1).validate(any()); + verify(validator2).validate(any()); + verify(validator3, never()).validate(any()); // Should not be called + } + + @Test + void validate_firstValidatorFails_doesNotCallOthers() { + var validator1 = mock(InsertionValidator.class); + var validator2 = mock(InsertionValidator.class); + + when(validator1.validate(any())).thenReturn(ValidationResult.invalid("First fail")); + + var composite = new CompositeValidator(List.of(validator1, validator2)); + var context = createDummyContext(); + + composite.validate(context); + + verify(validator1).validate(any()); + verify(validator2, never()).validate(any()); + } + + @Test + void standard_includesAllStandardValidators() { + var composite = CompositeValidator.standard(); + + // Test with scenario that should fail capacity validation + var trip = createTripWithCapacity(1, OSLO_CENTER, List.of(createStop(0, +1)), OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var context = new ValidationContext( + 2, + 3, + OSLO_EAST, + OSLO_WEST, + List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH), + timeline + ); + + var result = composite.validate(context); + assertFalse(result.isValid()); + } + + @Test + void standard_checksDirectionalConstraints() { + var composite = CompositeValidator.standard(); + + // Test with scenario that should fail directional validation + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + var context = new ValidationContext( + 1, + 2, + OSLO_SOUTH, // Backtracking + OSLO_NORTH, + List.of(OSLO_CENTER, OSLO_NORTH), + timeline + ); + + var result = composite.validate(context); + assertFalse(result.isValid()); + } + + @Test + void emptyValidator_acceptsAll() { + var composite = new CompositeValidator(List.of()); + var context = createDummyContext(); + + var result = composite.validate(context); + assertTrue(result.isValid()); + } + + @Test + void singleValidator_behavesCorrectly() { + var validator = mock(InsertionValidator.class); + when(validator.validate(any())).thenReturn(ValidationResult.valid()); + + var composite = new CompositeValidator(List.of(validator)); + var context = createDummyContext(); + + var result = composite.validate(context); + assertTrue(result.isValid()); + verify(validator).validate(context); + } + + private ValidationContext createDummyContext() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + return new ValidationContext( + 1, + 2, + OSLO_EAST, + OSLO_WEST, + List.of(OSLO_CENTER, OSLO_NORTH), + timeline + ); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java new file mode 100644 index 00000000000..6de219a495e --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java @@ -0,0 +1,181 @@ +package org.opentripplanner.ext.carpooling.validation; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; +import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationContext; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +class DirectionalValidatorTest { + + private DirectionalValidator validator; + + @BeforeEach + void setup() { + validator = new DirectionalValidator(); + } + + @Test + void validate_forwardProgress_returnsValid() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Insert along the route direction + var pickup = new WgsCoordinate(59.9239, 10.7522); // Between center and north + var dropoff = new WgsCoordinate(59.9339, 10.7522); + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_pickupCausesBacktracking_returnsInvalid() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Pickup south of starting point (backtracking) + var pickup = OSLO_SOUTH; + var dropoff = OSLO_NORTH; + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + assertFalse(result.isValid()); + assertTrue(result.reason().contains("backtrack") || result.reason().contains("forward")); + } + + @Test + void validate_dropoffCausesBacktracking_allowedWithinTolerance() { + // DirectionalValidator uses 90° tolerance, which allows some backtracking + // This test verifies that moderate backtracking within tolerance is accepted + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Dropoff south of pickup (some backtracking, but within 90° tolerance) + var pickup = new WgsCoordinate(59.9239, 10.7522); + var dropoff = OSLO_CENTER; // Back toward start + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + // With 90° tolerance, moderate backtracking is allowed for routing flexibility + assertTrue(result.isValid()); + } + + @Test + void validate_moderateDetour_allowed() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Slight eastward detour, but still generally northward + var pickup = new WgsCoordinate(59.9239, 10.7622); // North-east + var dropoff = new WgsCoordinate(59.9339, 10.7622); + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); // Should allow reasonable detours + } + + @Test + void validate_pickupAtBeginning_checksFromBoarding() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_MIDPOINT_NORTH, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Insert at very beginning + var pickup = new WgsCoordinate(59.9189, 10.7522); // Just north of center + var dropoff = OSLO_MIDPOINT_NORTH; + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_dropoffAtEnd_checkesToAlighting() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_MIDPOINT_NORTH, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Insert with dropoff near end + var pickup = OSLO_MIDPOINT_NORTH; + var dropoff = new WgsCoordinate(59.9389, 10.7522); // Just south of north + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_multiStopRoute_checksCorrectSegments() { + var stop1 = createStopAt(0, OSLO_EAST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Insert between first and second segment + var pickup = new WgsCoordinate(59.9189, 10.7722); // Between center and east + var dropoff = new WgsCoordinate(59.9289, 10.7722); + + var context = new ValidationContext(2, 3, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + assertTrue(result.isValid()); + } + + @Test + void validate_largePerpendicularDetour_allowedWithinTolerance() { + // DirectionalValidator uses 90° tolerance to allow perpendicular detours + // This is intentional to provide routing flexibility + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Perpendicular detour (going east when route goes north = 90°) + var pickup = new WgsCoordinate(59.9239, 10.7522); + var dropoff = new WgsCoordinate(59.9239, 10.8522); // Far east + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = validator.validate(context); + // 90° is exactly at the tolerance boundary and is allowed + assertTrue(result.isValid()); + } + + @Test + void validate_beyondToleranceDetour_returnsInvalid() { + // Test that detours beyond the configured tolerance are rejected + // Use a stricter validator to test this behavior + var strictValidator = new DirectionalValidator(45.0); // 45° tolerance + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + // Use 3 points so dropoff validation can occur between points + var routeCoords = List.of(OSLO_CENTER, OSLO_MIDPOINT_NORTH, OSLO_NORTH); + var timeline = PassengerCountTimeline.build(trip); + + // Pickup going north-northeast (~45°) - should pass + var pickup = new WgsCoordinate(59.9189, 10.7622); + // Dropoff going east (90° from north) - should exceed 45° tolerance + var dropoff = new WgsCoordinate(59.9239, 10.8522); + + var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); + + var result = strictValidator.validate(context); + // 90° deviation should be rejected with 45° tolerance + assertFalse(result.isValid()); + } +} From 6ddf01515cdb757b7f34542978bfbf5fe22ce7ac Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 10 Oct 2025 14:36:48 +0200 Subject: [PATCH 08/40] Expands logic with new filters and adds simpler bee-line routing as a heuristic to improve performance. --- .../PassengerDelayConstraints.java | 155 ++++++++ .../DirectionalCompatibilityFilter.java | 160 ++++++-- .../filter/DistanceBasedFilter.java | 143 +++++++ .../ext/carpooling/filter/FilterChain.java | 34 +- .../carpooling/filter/TimeBasedFilter.java | 86 ++++ .../ext/carpooling/filter/TripFilter.java | 88 +++- .../routing/OptimalInsertionStrategy.java | 178 ++++++++- .../service/DefaultCarpoolingService.java | 16 +- .../ext/carpooling/util/BeelineEstimator.java | 119 ++++++ .../carpooling/TestCarpoolTripBuilder.java | 38 ++ .../PassengerDelayConstraintsTest.java | 375 ++++++++++++++++++ .../DirectionalCompatibilityFilterTest.java | 82 ++++ .../filter/DistanceBasedFilterTest.java | 256 ++++++++++++ .../filter/TimeBasedFilterTest.java | 146 +++++++ .../carpooling/util/BeelineEstimatorTest.java | 273 +++++++++++++ 15 files changed, 2087 insertions(+), 62 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java new file mode 100644 index 00000000000..54b72f8ecf7 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java @@ -0,0 +1,155 @@ +package org.opentripplanner.ext.carpooling.constraints; + +import java.time.Duration; +import java.util.List; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates that inserting a new passenger does not cause excessive delays + * for existing passengers in a carpool trip. + *

+ * Ensures that no existing passenger experiences: + * - More than {@code maxDelay} additional wait time at their pickup location + * - More than {@code maxDelay} later arrival at their dropoff location + *

+ * This protects the rider experience by preventing situations where accepting + * one more passenger significantly inconveniences existing bookings. + */ +public class PassengerDelayConstraints { + + private static final Logger LOG = LoggerFactory.getLogger(PassengerDelayConstraints.class); + + /** + * Default maximum delay: 5 minutes. + * No existing passenger should wait more than 5 minutes longer or arrive + * more than 5 minutes later due to a new passenger insertion. + */ + public static final Duration DEFAULT_MAX_DELAY = Duration.ofMinutes(5); + + private final Duration maxDelay; + + /** + * Creates constraints with default 5-minute maximum delay. + */ + public PassengerDelayConstraints() { + this(DEFAULT_MAX_DELAY); + } + + /** + * Creates constraints with custom maximum delay. + * + * @param maxDelay Maximum acceptable delay for existing passengers + */ + public PassengerDelayConstraints(Duration maxDelay) { + if (maxDelay.isNegative()) { + throw new IllegalArgumentException("maxDelay must be non-negative"); + } + this.maxDelay = maxDelay; + } + + /** + * Checks if a passenger insertion satisfies delay constraints. + * + * @param originalCumulativeTimes Cumulative duration to each point in original route + * @param modifiedSegments Route segments after passenger insertion + * @param pickupPos Position where passenger pickup is inserted (1-indexed) + * @param dropoffPos Position where passenger dropoff is inserted (1-indexed) + * @return true if all existing passengers experience acceptable delays + */ + public boolean satisfiesConstraints( + Duration[] originalCumulativeTimes, + List> modifiedSegments, + int pickupPos, + int dropoffPos + ) { + // If no existing stops (only boarding and alighting), no constraint to check + if (originalCumulativeTimes.length <= 2) { + return true; + } + + // Calculate cumulative times for modified route + Duration[] modifiedTimes = new Duration[modifiedSegments.size() + 1]; + modifiedTimes[0] = Duration.ZERO; + for (int i = 0; i < modifiedSegments.size(); i++) { + GraphPath segment = modifiedSegments.get(i); + Duration segmentDuration = Duration.between( + segment.states.getFirst().getTime(), + segment.states.getLast().getTime() + ); + modifiedTimes[i + 1] = modifiedTimes[i].plus(segmentDuration); + } + + // Check delay at each existing stop (exclude boarding at 0 and alighting at end) + for ( + int originalIndex = 1; + originalIndex < originalCumulativeTimes.length - 1; + originalIndex++ + ) { + int modifiedIndex = getModifiedIndex(originalIndex, pickupPos, dropoffPos); + + Duration originalTime = originalCumulativeTimes[originalIndex]; + Duration modifiedTime = modifiedTimes[modifiedIndex]; + Duration delay = modifiedTime.minus(originalTime); + + if (delay.compareTo(maxDelay) > 0) { + LOG.debug( + "Insertion rejected: stop at position {} delayed by {}s (max: {}s)", + originalIndex, + delay.getSeconds(), + maxDelay.getSeconds() + ); + return false; + } + + LOG.trace( + "Stop at position {} delay: {}s (acceptable, max: {}s)", + originalIndex, + delay.getSeconds(), + maxDelay.getSeconds() + ); + } + + return true; + } + + /** + * Maps an index in the original route to the corresponding index in the + * modified route after passenger stops have been inserted. + * + * @param originalIndex Index in original route + * @param pickupPos Position where pickup was inserted (1-indexed) + * @param dropoffPos Position where dropoff was inserted (1-indexed, in original space) + * @return Corresponding index in modified route + */ + private int getModifiedIndex(int originalIndex, int pickupPos, int dropoffPos) { + int modifiedIndex = originalIndex; + + // Account for pickup insertion + // If the original point was at or after pickupPos, it shifts by 1 + if (originalIndex >= pickupPos) { + modifiedIndex++; + } + + // Account for dropoff insertion + // After pickup insertion, check if the shifted index is at or after dropoffPos + if (modifiedIndex >= dropoffPos) { + modifiedIndex++; + } + + return modifiedIndex; + } + + /** + * Gets the configured maximum delay. + * + * @return Maximum delay duration + */ + public Duration getMaxDelay() { + return maxDelay; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java index 3f36b404651..5973d1bd305 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java @@ -14,24 +14,46 @@ * Filters trips based on directional compatibility with the passenger journey. *

* This prevents carpooling from becoming a taxi service by ensuring trips and - * passengers are going in generally the same direction. Uses segment-based + * passengers are going in generally the same direction. Uses optimized segment-based * analysis to handle routes that take detours (e.g., driving around a lake). + *

+ * Performance Optimization: Only checks individual segments and the + * full route (O(n) complexity) rather than all possible segment ranges (O(n²)). + * This is sufficient for filtering while maintaining accuracy. */ public class DirectionalCompatibilityFilter implements TripFilter { private static final Logger LOG = LoggerFactory.getLogger(DirectionalCompatibilityFilter.class); - /** Default maximum bearing difference for compatibility */ + /** + * Default maximum bearing difference for compatibility. + * 60° allows for reasonable detours while preventing perpendicular or opposite directions. + */ public static final double DEFAULT_BEARING_TOLERANCE_DEGREES = 60.0; private final double bearingToleranceDegrees; + private final double corridorToleranceDegrees; public DirectionalCompatibilityFilter() { - this(DEFAULT_BEARING_TOLERANCE_DEGREES); + this(DEFAULT_BEARING_TOLERANCE_DEGREES, RouteGeometry.DEFAULT_CORRIDOR_TOLERANCE_DEGREES); } public DirectionalCompatibilityFilter(double bearingToleranceDegrees) { + this(bearingToleranceDegrees, RouteGeometry.DEFAULT_CORRIDOR_TOLERANCE_DEGREES); + } + + /** + * Creates a filter with custom bearing and corridor tolerances. + * + * @param bearingToleranceDegrees Maximum bearing difference (in degrees) + * @param corridorToleranceDegrees Maximum distance from route corridor (in degrees, ~1° = 111km) + */ + public DirectionalCompatibilityFilter( + double bearingToleranceDegrees, + double corridorToleranceDegrees + ) { this.bearingToleranceDegrees = bearingToleranceDegrees; + this.corridorToleranceDegrees = corridorToleranceDegrees; } @Override @@ -43,36 +65,69 @@ public boolean accepts( // Build route points list List routePoints = buildRoutePoints(trip); - // Check if passenger journey is compatible with any segment of the route + if (routePoints.size() < 2) { + LOG.warn("Trip {} has fewer than 2 route points, rejecting", trip.getId()); + return false; + } + + // Calculate passenger journey bearing double passengerBearing = DirectionalCalculator.calculateBearing( passengerPickup, passengerDropoff ); - // Try all possible segment ranges - for (int startIdx = 0; startIdx < routePoints.size() - 1; startIdx++) { - for (int endIdx = startIdx + 1; endIdx < routePoints.size(); endIdx++) { - if ( - isSegmentRangeCompatible( - routePoints, - startIdx, - endIdx, - passengerBearing, - passengerPickup, - passengerDropoff - ) - ) { - LOG.debug( - "Trip {} accepted: passenger journey aligns with route segments {} to {}", - trip.getId(), - startIdx, - endIdx - ); - return true; - } + // OPTIMIZATION: Instead of checking all O(n²) segment ranges, + // only check: + // 1. Individual segments (O(n)) - catches most compatible trips + // 2. Full route - handles end-to-end compatibility + + // Check individual segments + for (int i = 0; i < routePoints.size() - 1; i++) { + if ( + isSegmentCompatible( + routePoints.get(i), + routePoints.get(i + 1), + passengerBearing, + passengerPickup, + passengerDropoff, + i, + i + 1, + routePoints + ) + ) { + LOG.debug( + "Trip {} accepted: passenger journey aligns with segment {} ({} to {})", + trip.getId(), + i, + routePoints.get(i), + routePoints.get(i + 1) + ); + return true; } } + // Check full route as fallback (handles complex multi-segment compatibility) + if ( + isSegmentCompatible( + routePoints.get(0), + routePoints.get(routePoints.size() - 1), + passengerBearing, + passengerPickup, + passengerDropoff, + 0, + routePoints.size() - 1, + routePoints + ) + ) { + LOG.debug( + "Trip {} accepted: passenger journey aligns with full route ({} to {})", + trip.getId(), + routePoints.get(0), + routePoints.get(routePoints.size() - 1) + ); + return true; + } + LOG.debug( "Trip {} rejected by directional filter: passenger journey (bearing {}°) not aligned with any route segments", trip.getId(), @@ -102,30 +157,59 @@ private List buildRoutePoints(CarpoolTrip trip) { } /** - * Checks if a range of route segments is compatible with the passenger journey. + * Checks if a segment is compatible with the passenger journey. + * + * @param segmentStart Start coordinate of the segment + * @param segmentEnd End coordinate of the segment + * @param passengerBearing Bearing of passenger journey + * @param passengerPickup Passenger pickup location + * @param passengerDropoff Passenger dropoff location + * @param startIdx Start index in route points (for corridor calculation) + * @param endIdx End index in route points (for corridor calculation) + * @param allRoutePoints All route points (for corridor calculation) + * @return true if segment is directionally compatible and within corridor */ - private boolean isSegmentRangeCompatible( - List routePoints, - int startIdx, - int endIdx, + private boolean isSegmentCompatible( + WgsCoordinate segmentStart, + WgsCoordinate segmentEnd, double passengerBearing, WgsCoordinate passengerPickup, - WgsCoordinate passengerDropoff + WgsCoordinate passengerDropoff, + int startIdx, + int endIdx, + List allRoutePoints ) { - // Calculate overall bearing for this segment range - WgsCoordinate rangeStart = routePoints.get(startIdx); - WgsCoordinate rangeEnd = routePoints.get(endIdx); - double rangeBearing = DirectionalCalculator.calculateBearing(rangeStart, rangeEnd); + // Calculate segment bearing + double segmentBearing = DirectionalCalculator.calculateBearing(segmentStart, segmentEnd); // Check directional compatibility - double bearingDiff = DirectionalCalculator.bearingDifference(rangeBearing, passengerBearing); + double bearingDiff = DirectionalCalculator.bearingDifference(segmentBearing, passengerBearing); if (bearingDiff <= bearingToleranceDegrees) { // Also verify that pickup/dropoff are within the route corridor - List segmentPoints = routePoints.subList(startIdx, endIdx + 1); - return RouteGeometry.areBothWithinCorridor(segmentPoints, passengerPickup, passengerDropoff); + List segmentPoints = allRoutePoints.subList(startIdx, endIdx + 1); + return RouteGeometry.areBothWithinCorridor( + segmentPoints, + passengerPickup, + passengerDropoff, + corridorToleranceDegrees + ); } return false; } + + /** + * Gets the configured bearing tolerance in degrees. + */ + public double getBearingToleranceDegrees() { + return bearingToleranceDegrees; + } + + /** + * Gets the configured corridor tolerance in degrees. + */ + public double getCorridorToleranceDegrees() { + return corridorToleranceDegrees; + } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java new file mode 100644 index 00000000000..c3254cfb3c6 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java @@ -0,0 +1,143 @@ +package org.opentripplanner.ext.carpooling.filter; + +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filters trips based on geographic proximity to the passenger journey. + *

+ * Checks if the passenger's pickup and dropoff locations are both within + * a reasonable distance from the driver's main route (the direct line from + * trip boarding to alighting). This allows passengers to join trips where they + * share a segment of the driver's journey, while rejecting passengers whose + * journey is far off the driver's direct path. + */ +public class DistanceBasedFilter implements TripFilter { + + private static final Logger LOG = LoggerFactory.getLogger(DistanceBasedFilter.class); + + /** + * Default maximum distance: 50km. + * If both trip start and end are more than this distance from + * both passenger pickup and dropoff, the trip is rejected. + */ + public static final double DEFAULT_MAX_DISTANCE_METERS = 50_000; + + private final double maxDistanceMeters; + + public DistanceBasedFilter() { + this(DEFAULT_MAX_DISTANCE_METERS); + } + + public DistanceBasedFilter(double maxDistanceMeters) { + this.maxDistanceMeters = maxDistanceMeters; + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + WgsCoordinate tripStart = trip.boardingArea().getCoordinate(); + WgsCoordinate tripEnd = trip.alightingArea().getCoordinate(); + + // Calculate distance from passenger pickup to the driver's main route + double pickupDistanceToRoute = distanceToLineSegment(passengerPickup, tripStart, tripEnd); + + // Calculate distance from passenger dropoff to the driver's main route + double dropoffDistanceToRoute = distanceToLineSegment(passengerDropoff, tripStart, tripEnd); + + // Accept only if BOTH passenger locations are within threshold of the driver's route + boolean acceptable = + pickupDistanceToRoute <= maxDistanceMeters && dropoffDistanceToRoute <= maxDistanceMeters; + + if (!acceptable) { + LOG.debug( + "Trip {} rejected by distance filter: passenger journey too far from trip route. " + + "Pickup distance: {:.0f}m, Dropoff distance: {:.0f}m (max: {:.0f}m)", + trip.getId(), + pickupDistanceToRoute, + dropoffDistanceToRoute, + maxDistanceMeters + ); + } + + return acceptable; + } + + /** + * Calculates the distance from a point to a line segment. + *

+ * This finds the closest point on the line segment from lineStart to lineEnd, + * then calculates the spherical distance from the point to that closest point. + *

+ * The algorithm: + * 1. Projects the point onto the infinite line passing through lineStart and lineEnd + * 2. Clamps the projection to stay within the segment [lineStart, lineEnd] + * 3. Calculates the spherical distance from the point to the closest point on the segment + *

+ * Note: Uses lat/lon as if they were Cartesian coordinates for the projection + * calculation, which is an approximation. For typical carpooling distances + * (urban/suburban scale), this approximation is acceptable. + * + * @param point The point to measure from + * @param lineStart Start of the line segment + * @param lineEnd End of the line segment + * @return Distance in meters from point to the closest point on the line segment + */ + private double distanceToLineSegment( + WgsCoordinate point, + WgsCoordinate lineStart, + WgsCoordinate lineEnd + ) { + // If start and end are the same point, return distance to that point + if (lineStart.equals(lineEnd)) { + return SphericalDistanceLibrary.fastDistance( + point.asJtsCoordinate(), + lineStart.asJtsCoordinate() + ); + } + + // Calculate vector from lineStart to lineEnd + double dx = lineEnd.longitude() - lineStart.longitude(); + double dy = lineEnd.latitude() - lineStart.latitude(); + + // Calculate squared length of line segment + double lineLengthSquared = dx * dx + dy * dy; + + // Calculate projection parameter t + // t represents where the projection falls on the line segment: + // t = 0 means the projection is at lineStart + // t = 1 means the projection is at lineEnd + // t between 0 and 1 means the projection is between them + double t = + ((point.longitude() - lineStart.longitude()) * dx + + (point.latitude() - lineStart.latitude()) * dy) / + lineLengthSquared; + + // Clamp t to [0, 1] to ensure we stay on the segment + t = Math.max(0, Math.min(1, t)); + + // Calculate the closest point on the segment + double closestLon = lineStart.longitude() + t * dx; + double closestLat = lineStart.latitude() + t * dy; + WgsCoordinate closestPoint = new WgsCoordinate(closestLat, closestLon); + + // Return spherical distance from point to closest point on segment + return SphericalDistanceLibrary.fastDistance( + point.asJtsCoordinate(), + closestPoint.asJtsCoordinate() + ); + } + + /** + * Gets the configured maximum distance in meters. + */ + public double getMaxDistanceMeters() { + return maxDistanceMeters; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java index d5618da1f2d..a5ad2ea444e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java @@ -1,5 +1,6 @@ package org.opentripplanner.ext.carpooling.filter; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -11,6 +12,12 @@ *

* Filters are evaluated in order, with short-circuit evaluation: * as soon as one filter rejects a trip, evaluation stops. + *

+ * The standard filter chain includes (in order of performance impact): + * 1. CapacityFilter - Very fast (O(1)) + * 2. TimeBasedFilter - Very fast (O(1)) + * 3. DistanceBasedFilter - Fast (O(1) with 4 distance calculations) + * 4. DirectionalCompatibilityFilter - Medium (O(n) with n = number of stops) */ public class FilterChain implements TripFilter { @@ -25,10 +32,18 @@ public FilterChain(TripFilter... filters) { } /** - * Creates a standard filter chain with capacity and directional filters. + * Creates a standard filter chain with all recommended filters. + *

+ * Filters are ordered by performance impact (fastest first) to maximize + * the benefit of short-circuit evaluation. */ public static FilterChain standard() { - return new FilterChain(new CapacityFilter(), new DirectionalCompatibilityFilter()); + return new FilterChain( + new CapacityFilter(), // Fastest: O(1) + new TimeBasedFilter(), // Very fast: O(1) + new DistanceBasedFilter(), // Fast: O(1) with 4 distance calculations + new DirectionalCompatibilityFilter() // Medium: O(n) segments + ); } @Override @@ -45,6 +60,21 @@ public boolean accepts( return true; // All filters passed } + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + Instant passengerDepartureTime + ) { + for (TripFilter filter : filters) { + if (!filter.accepts(trip, passengerPickup, passengerDropoff, passengerDepartureTime)) { + return false; // Short-circuit: filter rejected the trip + } + } + return true; // All filters passed + } + /** * Adds a filter to the chain. */ diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java new file mode 100644 index 00000000000..a460240f14a --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java @@ -0,0 +1,86 @@ +package org.opentripplanner.ext.carpooling.filter; + +import java.time.Duration; +import java.time.Instant; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filters trips based on departure time compatibility with passenger request. + *

+ * Rejects trips that depart significantly before or after the passenger's + * requested departure time. This prevents matching passengers with trips + * that have already departed or won't depart for hours. + */ +public class TimeBasedFilter implements TripFilter { + + private static final Logger LOG = LoggerFactory.getLogger(TimeBasedFilter.class); + + /** + * Default time window: ±30 minutes from requested time. + * Trips departing outside this window are rejected. + */ + public static final Duration DEFAULT_TIME_WINDOW = Duration.ofMinutes(30); + + private final Duration timeWindow; + + public TimeBasedFilter() { + this(DEFAULT_TIME_WINDOW); + } + + public TimeBasedFilter(Duration timeWindow) { + this.timeWindow = timeWindow; + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + // Cannot filter without time information + LOG.warn( + "TimeBasedFilter called without time parameter - accepting all trips. " + + "Use accepts(..., Instant) instead." + ); + return true; + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + Instant passengerDepartureTime + ) { + Instant tripStartTime = trip.startTime().toInstant(); + + // Calculate time difference + Duration timeDiff = Duration.between(tripStartTime, passengerDepartureTime).abs(); + + // Check if within time window + boolean withinWindow = timeDiff.compareTo(timeWindow) <= 0; + + if (!withinWindow) { + LOG.debug( + "Trip {} rejected by time filter: trip departs at {}, passenger requests {}, diff = {} (window = {})", + trip.getId(), + trip.startTime(), + passengerDepartureTime, + timeDiff, + timeWindow + ); + } + + return withinWindow; + } + + /** + * Gets the configured time window. + */ + public Duration getTimeWindow() { + return timeWindow; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java index 2b46676b7db..ae298c2582a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java @@ -1,5 +1,6 @@ package org.opentripplanner.ext.carpooling.filter; +import java.time.Instant; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.framework.geometry.WgsCoordinate; @@ -7,7 +8,7 @@ * Interface for filtering carpool trips before expensive routing calculations. *

* Filters are applied as a pre-screening mechanism to quickly eliminate - * incompatible trips based on various criteria (direction, capacity, etc.). + * incompatible trips based on various criteria (direction, capacity, time, distance, etc.). */ @FunctionalInterface public interface TripFilter { @@ -21,6 +22,28 @@ public interface TripFilter { */ boolean accepts(CarpoolTrip trip, WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff); + /** + * Checks if a trip passes this filter for the given passenger request with time information. + *

+ * Default implementation delegates to the simpler {@link #accepts(CarpoolTrip, WgsCoordinate, WgsCoordinate)} + * method, ignoring the time parameter. Time-aware filters should override this method. + * + * @param trip The carpool trip to evaluate + * @param passengerPickup Passenger's pickup location + * @param passengerDropoff Passenger's dropoff location + * @param passengerDepartureTime Passenger's requested departure time + * @return true if the trip passes the filter, false otherwise + */ + default boolean accepts( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + Instant passengerDepartureTime + ) { + // Default: ignore time and delegate to coordinate-only method + return accepts(trip, passengerPickup, passengerDropoff); + } + /** * Returns a filter that always accepts all trips. */ @@ -32,22 +55,75 @@ static TripFilter acceptAll() { * Returns a filter that combines this filter with another using AND logic. */ default TripFilter and(TripFilter other) { - return (trip, pickup, dropoff) -> - this.accepts(trip, pickup, dropoff) && other.accepts(trip, pickup, dropoff); + return new TripFilter() { + @Override + public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, WgsCoordinate dropoff) { + return ( + TripFilter.this.accepts(trip, pickup, dropoff) && other.accepts(trip, pickup, dropoff) + ); + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate pickup, + WgsCoordinate dropoff, + Instant time + ) { + return ( + TripFilter.this.accepts(trip, pickup, dropoff, time) && + other.accepts(trip, pickup, dropoff, time) + ); + } + }; } /** * Returns a filter that combines this filter with another using OR logic. */ default TripFilter or(TripFilter other) { - return (trip, pickup, dropoff) -> - this.accepts(trip, pickup, dropoff) || other.accepts(trip, pickup, dropoff); + return new TripFilter() { + @Override + public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, WgsCoordinate dropoff) { + return ( + TripFilter.this.accepts(trip, pickup, dropoff) || other.accepts(trip, pickup, dropoff) + ); + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate pickup, + WgsCoordinate dropoff, + Instant time + ) { + return ( + TripFilter.this.accepts(trip, pickup, dropoff, time) || + other.accepts(trip, pickup, dropoff, time) + ); + } + }; } /** * Returns a filter that negates this filter. */ default TripFilter negate() { - return (trip, pickup, dropoff) -> !this.accepts(trip, pickup, dropoff); + return new TripFilter() { + @Override + public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, WgsCoordinate dropoff) { + return !TripFilter.this.accepts(trip, pickup, dropoff); + } + + @Override + public boolean accepts( + CarpoolTrip trip, + WgsCoordinate pickup, + WgsCoordinate dropoff, + Instant time + ) { + return !TripFilter.this.accepts(trip, pickup, dropoff, time); + } + }; } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java index facb8129be7..0beaf2e43b2 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java @@ -4,8 +4,10 @@ import java.util.ArrayList; import java.util.List; import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; import org.opentripplanner.ext.carpooling.model.CarpoolStop; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.util.BeelineEstimator; import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; import org.opentripplanner.ext.carpooling.validation.InsertionValidator; import org.opentripplanner.framework.geometry.WgsCoordinate; @@ -38,10 +40,31 @@ public class OptimalInsertionStrategy { private final InsertionValidator validator; private final RoutingFunction routingFunction; + private final PassengerDelayConstraints delayConstraints; + private final BeelineEstimator beelineEstimator; public OptimalInsertionStrategy(InsertionValidator validator, RoutingFunction routingFunction) { + this(validator, routingFunction, new PassengerDelayConstraints(), new BeelineEstimator()); + } + + public OptimalInsertionStrategy( + InsertionValidator validator, + RoutingFunction routingFunction, + PassengerDelayConstraints delayConstraints + ) { + this(validator, routingFunction, delayConstraints, new BeelineEstimator()); + } + + public OptimalInsertionStrategy( + InsertionValidator validator, + RoutingFunction routingFunction, + PassengerDelayConstraints delayConstraints, + BeelineEstimator beelineEstimator + ) { this.validator = validator; this.routingFunction = routingFunction; + this.delayConstraints = delayConstraints; + this.beelineEstimator = beelineEstimator; } /** @@ -68,12 +91,17 @@ public InsertionCandidate findOptimalInsertion( trip.availableSeats() ); - // Calculate baseline duration (current route without new passenger) - Duration baselineDuration = calculateRouteDuration(routePoints); - if (baselineDuration == null) { - LOG.warn("Could not calculate baseline duration for trip {}", trip.getId()); + // Calculate baseline duration and cumulative times (current route without new passenger) + Duration[] originalCumulativeTimes = calculateCumulativeTimes(routePoints); + if (originalCumulativeTimes == null) { + LOG.warn("Could not calculate baseline route for trip {}", trip.getId()); return null; } + Duration baselineDuration = originalCumulativeTimes[originalCumulativeTimes.length - 1]; + + // Calculate beeline estimates for original route (for early rejection heuristic) + List originalCoords = routePoints.stream().map(RoutePoint::coordinate).toList(); + Duration[] originalBeelineTimes = beelineEstimator.calculateCumulativeTimes(originalCoords); InsertionCandidate bestCandidate = null; Duration minAdditionalDuration = Duration.ofDays(1); @@ -105,6 +133,28 @@ public InsertionCandidate findOptimalInsertion( continue; } + // Beeline delay heuristic check (early rejection before expensive A* routing) + // Only check if there are existing stops to protect + if (originalCoords.size() > 2) { + if ( + !passesBeelineDelayCheck( + originalCoords, + originalBeelineTimes, + passengerPickup, + passengerDropoff, + pickupPos, + dropoffPos + ) + ) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected by beeline delay heuristic", + pickupPos, + dropoffPos + ); + continue; // Skip expensive A* routing! + } + } + // Calculate route with insertion InsertionCandidate candidate = evaluateInsertion( trip, @@ -113,7 +163,8 @@ public InsertionCandidate findOptimalInsertion( dropoffPos, passengerPickup, passengerDropoff, - baselineDuration + baselineDuration, + originalCumulativeTimes ); if (candidate != null) { @@ -162,7 +213,8 @@ private InsertionCandidate evaluateInsertion( int dropoffPos, WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff, - Duration baselineDuration + Duration baselineDuration, + Duration[] originalCumulativeTimes ) { // Build modified route with passenger stops inserted List modifiedPoints = new ArrayList<>(originalPoints); @@ -189,6 +241,23 @@ private InsertionCandidate evaluateInsertion( ); } + // Check passenger delay constraints + if ( + !delayConstraints.satisfiesConstraints( + originalCumulativeTimes, + segments, + pickupPos, + dropoffPos + ) + ) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected by delay constraints", + pickupPos, + dropoffPos + ); + return null; + } + return new InsertionCandidate( trip, pickupPos, @@ -220,10 +289,15 @@ private List buildRoutePoints(CarpoolTrip trip) { } /** - * Calculates the total duration for a route. + * Calculates cumulative durations to each point in the route. + * Returns an array where index i contains the cumulative duration to reach point i. + * + * @param routePoints The route points + * @return Array of cumulative durations, or null if routing fails */ - private Duration calculateRouteDuration(List routePoints) { - Duration total = Duration.ZERO; + private Duration[] calculateCumulativeTimes(List routePoints) { + Duration[] cumulativeTimes = new Duration[routePoints.size()]; + cumulativeTimes[0] = Duration.ZERO; for (int i = 0; i < routePoints.size() - 1; i++) { GenericLocation from = toGenericLocation(routePoints.get(i).coordinate()); @@ -234,18 +308,98 @@ private Duration calculateRouteDuration(List routePoints) { return null; } - total = total.plus( - Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) + Duration segmentDuration = Duration.between( + segment.states.getFirst().getTime(), + segment.states.getLast().getTime() ); + cumulativeTimes[i + 1] = cumulativeTimes[i].plus(segmentDuration); } - return total; + return cumulativeTimes; } private GenericLocation toGenericLocation(WgsCoordinate coord) { return GenericLocation.fromCoordinate(coord.latitude(), coord.longitude()); } + /** + * Checks if an insertion position passes the beeline delay heuristic. + * This is a fast, optimistic check using straight-line distance estimates. + * If this check fails, we know the actual A* routing will also fail, so we + * can skip the expensive routing calculation. + * + * @param originalCoords Original route coordinates + * @param originalBeelineTimes Beeline cumulative times for original route + * @param passengerPickup Passenger pickup location + * @param passengerDropoff Passenger dropoff location + * @param pickupPos Pickup insertion position (1-indexed) + * @param dropoffPos Dropoff insertion position (1-indexed) + * @return true if insertion might satisfy delay constraints (proceed with A* routing) + */ + private boolean passesBeelineDelayCheck( + List originalCoords, + Duration[] originalBeelineTimes, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + int pickupPos, + int dropoffPos + ) { + // Build modified coordinate list with passenger stops inserted + List modifiedCoords = new ArrayList<>(originalCoords); + modifiedCoords.add(pickupPos, passengerPickup); + modifiedCoords.add(dropoffPos, passengerDropoff); + + // Calculate beeline times for modified route + Duration[] modifiedBeelineTimes = beelineEstimator.calculateCumulativeTimes(modifiedCoords); + + // Check delays at each existing stop (exclude boarding at 0 and alighting at end) + for (int originalIndex = 1; originalIndex < originalCoords.size() - 1; originalIndex++) { + int modifiedIndex = getModifiedIndex(originalIndex, pickupPos, dropoffPos); + + Duration originalTime = originalBeelineTimes[originalIndex]; + Duration modifiedTime = modifiedBeelineTimes[modifiedIndex]; + Duration beelineDelay = modifiedTime.minus(originalTime); + + // If even the optimistic beeline estimate exceeds threshold, actual routing will too + if (beelineDelay.compareTo(delayConstraints.getMaxDelay()) > 0) { + LOG.trace( + "Stop at position {} has beeline delay {}s (exceeds {}s threshold)", + originalIndex, + beelineDelay.getSeconds(), + delayConstraints.getMaxDelay().getSeconds() + ); + return false; // Reject early! + } + } + + return true; // Passes beeline check, proceed with A* routing + } + + /** + * Maps an index in the original route to the corresponding index in the + * modified route after passenger stops have been inserted. + * + * @param originalIndex Index in original route + * @param pickupPos Position where pickup was inserted (1-indexed) + * @param dropoffPos Position where dropoff was inserted (1-indexed) + * @return Corresponding index in modified route + */ + private int getModifiedIndex(int originalIndex, int pickupPos, int dropoffPos) { + int modifiedIndex = originalIndex; + + // Account for pickup insertion + if (originalIndex >= pickupPos) { + modifiedIndex++; + } + + // Account for dropoff insertion (after pickup has been inserted) + if (modifiedIndex >= dropoffPos) { + modifiedIndex++; + } + + return modifiedIndex; + } + /** * Functional interface for street routing. */ diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index 87bfaef4b5b..4fc2951d207 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -76,20 +76,28 @@ public List route(RouteRequest request) throws RoutingValidationExcep // Validate request validateRequest(request); - // Extract passenger coordinates + // Extract passenger coordinates and time WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate()); WgsCoordinate passengerDropoff = new WgsCoordinate(request.to().getCoordinate()); + var passengerDepartureTime = request.dateTime(); - LOG.debug("Finding carpool itineraries from {} to {}", passengerPickup, passengerDropoff); + LOG.debug( + "Finding carpool itineraries from {} to {} at {}", + passengerPickup, + passengerDropoff, + passengerDepartureTime + ); // Get all trips from repository var allTrips = repository.getCarpoolTrips(); LOG.debug("Repository contains {} carpool trips", allTrips.size()); - // Apply pre-filters (fast rejection) + // Apply pre-filters (fast rejection) - pass time for time-aware filters var candidateTrips = allTrips .stream() - .filter(trip -> preFilters.accepts(trip, passengerPickup, passengerDropoff)) + .filter(trip -> + preFilters.accepts(trip, passengerPickup, passengerDropoff, passengerDepartureTime) + ) .toList(); LOG.debug( diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java new file mode 100644 index 00000000000..366b982aa24 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java @@ -0,0 +1,119 @@ +package org.opentripplanner.ext.carpooling.util; + +import java.time.Duration; +import java.util.List; +import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +/** + * Provides fast, low-resolution travel time estimates based on beeline (straight-line) distances. + *

+ * Used as a heuristic to quickly reject incompatible insertion positions before + * performing expensive A* street routing. The estimates are intentionally optimistic + * (lower bounds) to ensure we never incorrectly reject valid insertions. + *

+ * Formula: duration = (beeline_distance × detour_factor) / average_speed + *

+ * The detour factor accounts for the fact that street routes are rarely straight lines. + * Typical values: 1.2-1.5, with 1.3 being a reasonable default for urban areas. + */ +public class BeelineEstimator { + + /** + * Default detour factor: 1.3 + * Assumes actual street routes are ~30% longer than straight-line distance. + * This is conservative - works well for most urban areas. + */ + public static final double DEFAULT_DETOUR_FACTOR = 1.3; + + /** + * Default average speed: 10 m/s (~36 km/h or ~22 mph) + * Typical urban carpooling speed accounting for traffic, turns, stops. + */ + public static final double DEFAULT_SPEED_MPS = 10.0; + + private final double detourFactor; + private final double speedMps; + + /** + * Creates estimator with default parameters. + */ + public BeelineEstimator() { + this(DEFAULT_DETOUR_FACTOR, DEFAULT_SPEED_MPS); + } + + /** + * Creates estimator with custom parameters. + * + * @param detourFactor Factor by which street routes are longer than beeline (typically 1.2-1.5) + * @param speedMps Average travel speed in meters per second + */ + public BeelineEstimator(double detourFactor, double speedMps) { + if (detourFactor < 1.0) { + throw new IllegalArgumentException("detourFactor must be >= 1.0 (got " + detourFactor + ")"); + } + if (speedMps <= 0) { + throw new IllegalArgumentException("speedMps must be positive (got " + speedMps + ")"); + } + this.detourFactor = detourFactor; + this.speedMps = speedMps; + } + + /** + * Estimates travel duration between two points using beeline distance. + * + * @param from Starting coordinate + * @param to Ending coordinate + * @return Estimated duration + */ + public Duration estimateDuration(WgsCoordinate from, WgsCoordinate to) { + double beelineDistance = SphericalDistanceLibrary.fastDistance( + from.asJtsCoordinate(), + to.asJtsCoordinate() + ); + double routeDistance = beelineDistance * detourFactor; + double seconds = routeDistance / speedMps; + return Duration.ofSeconds((long) seconds); + } + + /** + * Calculates cumulative travel times to each point in a route. + * Returns an array where index i contains the cumulative duration from the start to point i. + * + * @param points Route points in order + * @return Array of cumulative durations (first element is always Duration.ZERO) + */ + public Duration[] calculateCumulativeTimes(List points) { + if (points.isEmpty()) { + return new Duration[0]; + } + + Duration[] cumulativeTimes = new Duration[points.size()]; + cumulativeTimes[0] = Duration.ZERO; + + for (int i = 0; i < points.size() - 1; i++) { + Duration segmentDuration = estimateDuration(points.get(i), points.get(i + 1)); + cumulativeTimes[i + 1] = cumulativeTimes[i].plus(segmentDuration); + } + + return cumulativeTimes; + } + + /** + * Gets the configured detour factor. + * + * @return Detour factor + */ + public double getDetourFactor() { + return detourFactor; + } + + /** + * Gets the configured average speed in meters per second. + * + * @return Speed in m/s + */ + public double getSpeedMps() { + return speedMps; + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java index 80cb2ceeef6..7e9b622d4db 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java @@ -24,6 +24,17 @@ public static CarpoolTrip createSimpleTrip(WgsCoordinate boarding, WgsCoordinate return createTripWithCapacity(4, boarding, List.of(), alighting); } + /** + * Creates a simple trip with specific departure time. + */ + public static CarpoolTrip createSimpleTripWithTime( + WgsCoordinate boarding, + WgsCoordinate alighting, + ZonedDateTime startTime + ) { + return createTripWithTime(startTime, 4, boarding, List.of(), alighting); + } + /** * Creates a trip with specified stops. */ @@ -83,6 +94,33 @@ public static CarpoolTrip createTripWithDeviationBudget( .build(); } + /** + * Creates a trip with specific start time and all other parameters. + * End time is calculated as startTime + 1 hour. + */ + public static CarpoolTrip createTripWithTime( + ZonedDateTime startTime, + int seats, + WgsCoordinate boarding, + List stops, + WgsCoordinate alighting + ) { + return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder( + org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( + "TEST", + "trip-" + idCounter.incrementAndGet() + ) + ) + .withBoardingArea(createAreaStop(boarding)) + .withAlightingArea(createAreaStop(alighting)) + .withStops(stops) + .withAvailableSeats(seats) + .withStartTime(startTime) + .withEndTime(startTime.plusHours(1)) + .withDeviationBudget(Duration.ofMinutes(10)) + .build(); + } + /** * Creates a CarpoolStop with specified sequence (0-based) and passenger delta. */ diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java new file mode 100644 index 00000000000..32ab5dba36a --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java @@ -0,0 +1,375 @@ +package org.opentripplanner.ext.carpooling.constraints; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.MockGraphPathFactory; +import org.opentripplanner.ext.carpooling.routing.RoutePoint; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +class PassengerDelayConstraintsTest { + + private PassengerDelayConstraints constraints; + + @BeforeEach + void setup() { + constraints = new PassengerDelayConstraints(); + } + + @Test + void satisfiesConstraints_noExistingStops_alwaysAccepts() { + // Route with only boarding and alighting (no stops) + List originalPoints = List.of( + new RoutePoint(OSLO_CENTER, "Boarding"), + new RoutePoint(OSLO_NORTH, "Alighting") + ); + + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10) }; + + // Modified route with passenger inserted + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)) + ); + + // Should accept - no existing passengers to protect + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 2)); + } + + @Test + void satisfiesConstraints_delayWellUnderThreshold_accepts() { + // Original route: boarding -> stop1 -> alighting + List originalPoints = List.of( + new RoutePoint(OSLO_CENTER, "Boarding"), + new RoutePoint(OSLO_EAST, "Stop1"), + new RoutePoint(OSLO_NORTH, "Alighting") + ); + + // Original timings: 0min -> 5min -> 15min + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(5), Duration.ofMinutes(15) }; + + // Modified route: boarding -> pickup -> stop1 -> dropoff -> alighting + // Timings: 0min -> 3min -> 7min -> 12min -> 17min + // Stop1 delay: 7min - 5min = 2min (well under 5min threshold) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(4)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void satisfiesConstraints_delayExactlyAtThreshold_accepts() { + // Original route with one stop + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Modified route where stop1 is delayed by exactly 5 minutes + // Timings: 0min -> 5min -> 15min -> 20min -> 25min + // Stop1 delay: 15min - 10min = 5min (exactly at threshold) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void satisfiesConstraints_delayOverThreshold_rejects() { + // Original route with one stop + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Modified route where stop1 is delayed by 6 minutes (over 5min threshold) + // Timings: 0min -> 5min -> 16min -> 21min -> 26min + // Stop1 delay: 16min - 10min = 6min (exceeds threshold) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertFalse(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void satisfiesConstraints_multipleStops_oneOverThreshold_rejects() { + // Original route: boarding -> stop1 -> stop2 -> alighting + Duration[] originalTimes = { + Duration.ZERO, + Duration.ofMinutes(10), + Duration.ofMinutes(20), + Duration.ofMinutes(30), + }; + + // Modified route where stop1 is ok (3min delay) but stop2 exceeds (7min delay) + // Timings: 0min -> 5min -> 13min -> 18min -> 27min -> 32min + // Stop1 delay: 13min - 10min = 3min ✓ + // Stop2 delay: 27min - 20min = 7min ✗ + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(9)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertFalse(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void satisfiesConstraints_multipleStops_allUnderThreshold_accepts() { + // Original route: boarding -> stop1 -> stop2 -> alighting + Duration[] originalTimes = { + Duration.ZERO, + Duration.ofMinutes(10), + Duration.ofMinutes(20), + Duration.ofMinutes(30), + }; + + // Modified route where both stops have acceptable delays + // Timings: 0min -> 5min -> 12min -> 17min -> 24min -> 34min + // Stop1 delay: 12min - 10min = 2min ✓ + // Stop2 delay: 24min - 20min = 4min ✓ + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void satisfiesConstraints_passengerBeforeAllStops_checksAllStops() { + // Original route: boarding -> stop1 -> stop2 -> alighting + Duration[] originalTimes = { + Duration.ZERO, + Duration.ofMinutes(10), + Duration.ofMinutes(20), + Duration.ofMinutes(30), + }; + + // Passenger inserted at very beginning (pickup at 1, dropoff at 2) + // Modified: boarding -> pickup -> dropoff -> stop1 -> stop2 -> alighting + // Mapping: stop1 (orig 1) -> mod 3, stop2 (orig 2) -> mod 4 + // Timings: 0min -> 3min -> 5min -> 13min -> 24min -> 34min + // Stop1 delay: 13min - 10min = 3min ✓ + // Stop2 delay: 24min - 20min = 4min ✓ + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(2)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 2)); + } + + @Test + void satisfiesConstraints_passengerAfterAllStops_checksAllStops() { + // Original route: boarding -> stop1 -> stop2 -> alighting + Duration[] originalTimes = { + Duration.ZERO, + Duration.ofMinutes(10), + Duration.ofMinutes(20), + Duration.ofMinutes(30), + }; + + // Passenger inserted at very end (pickup at 3, dropoff at 4) + // Modified: boarding -> stop1 -> stop2 -> pickup -> dropoff -> alighting + // Mapping: stop1 (orig 1) -> mod 1, stop2 (orig 2) -> mod 2 + // Even though passenger comes after, routing to pickup might cause delays + // Timings: 0min -> 11min -> 22min -> 27min -> 30min -> 40min + // Stop1 delay: 11min - 10min = 1min ✓ + // Stop2 delay: 22min - 20min = 2min ✓ + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 3, 4)); + } + + @Test + void satisfiesConstraints_passengerBetweenStops_checksAllStops() { + // Original route: boarding -> stop1 -> stop2 -> alighting + Duration[] originalTimes = { + Duration.ZERO, + Duration.ofMinutes(10), + Duration.ofMinutes(20), + Duration.ofMinutes(30), + }; + + // Passenger inserted between stops (pickup at 2, dropoff at 3) + // Modified: boarding -> stop1 -> pickup -> dropoff -> stop2 -> alighting + // Mapping: stop1 (orig 1) -> mod 1, stop2 (orig 2) -> mod 4 + // Timings: 0min -> 11min -> 14min -> 17min -> 24min -> 34min + // Stop1 delay: 11min - 10min = 1min ✓ + // Stop2 delay: 24min - 20min = 4min ✓ + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 2, 3)); + } + + @Test + void customMaxDelay_acceptsWithinCustomThreshold() { + var customConstraints = new PassengerDelayConstraints(Duration.ofMinutes(10)); + + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Stop1 delayed by 8 minutes (within 10min custom threshold) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(13)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertTrue(customConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void customMaxDelay_rejectsOverCustomThreshold() { + var customConstraints = new PassengerDelayConstraints(Duration.ofMinutes(2)); + + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Stop1 delayed by 3 minutes (over 2min custom threshold) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertFalse(customConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void customMaxDelay_zeroTolerance_rejectsAnyDelay() { + var strictConstraints = new PassengerDelayConstraints(Duration.ZERO); + + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Stop1 delayed by even 1 second + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5).plusSeconds(1)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertFalse(strictConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void customMaxDelay_veryPermissive_acceptsLargeDelays() { + var permissiveConstraints = new PassengerDelayConstraints(Duration.ofHours(1)); + + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Stop1 delayed by 30 minutes (well within 1 hour threshold) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(35)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertTrue(permissiveConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void getMaxDelay_returnsConfiguredValue() { + assertEquals(Duration.ofMinutes(5), constraints.getMaxDelay()); + + var customConstraints = new PassengerDelayConstraints(Duration.ofMinutes(10)); + assertEquals(Duration.ofMinutes(10), customConstraints.getMaxDelay()); + } + + @Test + void defaultMaxDelay_isFiveMinutes() { + assertEquals(Duration.ofMinutes(5), PassengerDelayConstraints.DEFAULT_MAX_DELAY); + } + + @Test + void constructor_negativeDelay_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new PassengerDelayConstraints(Duration.ofMinutes(-1)) + ); + } + + @Test + void satisfiesConstraints_noDelay_accepts() { + // Route where insertion doesn't add any delay + Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; + + // Modified route where stop1 arrives at exactly the same time + // (perfect routing somehow) + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(4)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(6)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + } + + @Test + void satisfiesConstraints_tripWithManyStops_checksAll() { + // Original route with 5 stops + Duration[] originalTimes = { + Duration.ZERO, + Duration.ofMinutes(10), + Duration.ofMinutes(20), + Duration.ofMinutes(30), + Duration.ofMinutes(40), + Duration.ofMinutes(50), + Duration.ofMinutes(60), + }; + + // Insert passenger between stop2 and stop3 (positions 3, 4) + // All stops should have delays <= 5 minutes + // Modified indices: 0,1,2,pickup@3,dropoff@4,3,4,5,6 + List> modifiedSegments = List.of( + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(2)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(9)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) + ); + + assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 3, 4)); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java index c515481129a..1f0e63eaa8f 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java @@ -132,4 +132,86 @@ void accepts_passengerWithinCorridorButWrongDirection_returnsFalse() { assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); } + + @Test + void customBearingTolerance_acceptsWithinCustomTolerance() { + // Custom filter with 90° tolerance (very permissive) + var customFilter = new DirectionalCompatibilityFilter(90.0); + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + + // Passenger going east (90° perpendicular) + var passengerPickup = OSLO_CENTER; + var passengerDropoff = OSLO_EAST; + + // Should accept with 90° tolerance (default 60° would reject) + assertTrue(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void customBearingTolerance_rejectsOutsideCustomTolerance() { + // Custom filter with 30° tolerance (strict) + var customFilter = new DirectionalCompatibilityFilter(30.0); + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + + // Passenger going northeast (~45° off) + var passengerPickup = OSLO_CENTER; + var passengerDropoff = OSLO_NORTHEAST; + + // Should reject with 30° tolerance (default 60° would accept) + assertFalse(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void customCorridorTolerance_acceptsWithinWiderCorridor() { + // Custom filter with very wide corridor (0.5° ≈ 55km) + var customFilter = new DirectionalCompatibilityFilter(60.0, 0.5); + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger far east but directionally aligned + var passengerPickup = new WgsCoordinate(59.920, 11.2); // Far east + var passengerDropoff = new WgsCoordinate(59.950, 11.2); + + // Should accept with wide corridor + assertTrue(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void customCorridorTolerance_rejectsOutsideNarrowCorridor() { + // Custom filter with narrow corridor (0.01° ≈ 1.1km) + var customFilter = new DirectionalCompatibilityFilter(60.0, 0.01); + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger slightly east and directionally aligned + var passengerPickup = new WgsCoordinate(59.920, 10.80); // Slightly east + var passengerDropoff = new WgsCoordinate(59.950, 10.80); + + // Should reject with narrow corridor + assertFalse(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void getBearingToleranceDegrees_returnsConfiguredValue() { + var customFilter = new DirectionalCompatibilityFilter(45.0); + assertEquals(45.0, customFilter.getBearingToleranceDegrees()); + } + + @Test + void getCorridorToleranceDegrees_returnsConfiguredValue() { + var customFilter = new DirectionalCompatibilityFilter(60.0, 0.2); + assertEquals(0.2, customFilter.getCorridorToleranceDegrees()); + } + + @Test + void defaultBearingTolerance_is60Degrees() { + assertEquals(60.0, filter.getBearingToleranceDegrees()); + } + + @Test + void defaultCorridorTolerance_is0Point1Degrees() { + assertEquals(0.1, filter.getCorridorToleranceDegrees()); + } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java new file mode 100644 index 00000000000..0b0cf9565cd --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java @@ -0,0 +1,256 @@ +package org.opentripplanner.ext.carpooling.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +class DistanceBasedFilterTest { + + private DistanceBasedFilter filter; + + @BeforeEach + void setup() { + filter = new DistanceBasedFilter(); + } + + @Test + void accepts_passengerAlongRoute_returnsTrue() { + // Trip from Oslo Center (59.9139, 10.7522) to Oslo North (59.9549, 10.7922) + // This is roughly northeast direction + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger journey along approximately the same line + var passengerPickup = new WgsCoordinate(59.920, 10.760); + var passengerDropoff = new WgsCoordinate(59.940, 10.780); + + // Both points should be very close to the trip's direct line + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerParallelToRoute_nearRoute_returnsTrue() { + // Trip from Oslo Center to Oslo North (going north-northeast) + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger journey parallel to the route, but slightly to the west + // Within 50km perpendicular distance + var passengerPickup = new WgsCoordinate(59.920, 10.740); + var passengerDropoff = new WgsCoordinate(59.940, 10.760); + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void rejects_passengerPerpendicularToRoute_farAway_returnsFalse() { + // Trip from Oslo Center to Oslo North (going north-northeast) + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger journey perpendicular to the route, far to the west + // > 50km perpendicular distance from the route line + var passengerPickup = new WgsCoordinate(59.9139, 9.5); // Far west + var passengerDropoff = new WgsCoordinate(59.9549, 9.5); // Still far west + + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void rejects_passengerInDifferentCity_returnsFalse() { + // Trip from Oslo Center to Oslo North + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger in Bergen (~300km away) + var passengerPickup = new WgsCoordinate(60.39, 5.32); + var passengerDropoff = new WgsCoordinate(60.40, 5.33); + + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void rejects_oneLocationNear_otherLocationFar_returnsFalse() { + // Simple horizontal trip (east-west, same latitude) + var tripStart = new WgsCoordinate(59.9, 10.70); + var tripEnd = new WgsCoordinate(59.9, 10.80); + var trip = createSimpleTrip(tripStart, tripEnd); + + // Pickup on the route, but dropoff far to the north (>50km perpendicular) + // At this latitude, 0.5° latitude ≈ 55km + var passengerPickup = new WgsCoordinate(59.9, 10.75); // On route + var passengerDropoff = new WgsCoordinate(59.9 + 0.5, 10.75); // Far north + + // Should reject because BOTH locations must be near the route + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void rejects_pickupFar_dropoffNear_returnsFalse() { + // Simple horizontal trip (east-west, same latitude) + var tripStart = new WgsCoordinate(59.9, 10.70); + var tripEnd = new WgsCoordinate(59.9, 10.80); + var trip = createSimpleTrip(tripStart, tripEnd); + + // Pickup far to the north (>50km perpendicular), dropoff on the route + // At this latitude, 0.5° latitude ≈ 55km + var passengerPickup = new WgsCoordinate(59.9 + 0.5, 10.75); // Far north + var passengerDropoff = new WgsCoordinate(59.9, 10.75); // On route + + // Should reject because BOTH locations must be near the route + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_longTripShortPassengerSegment_returnsTrue() { + // Long driver trip from Oslo to much further north + var farNorth = new WgsCoordinate(60.5, 10.8); + var trip = createSimpleTrip(OSLO_CENTER, farNorth); + + // Short passenger segment along the driver's route + var passengerPickup = new WgsCoordinate(59.920, 10.760); + var passengerDropoff = new WgsCoordinate(59.940, 10.780); + + // Should accept - passenger is riding only a small segment of a long trip + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerNearRouteEndpoints_returnsTrue() { + // Trip from Oslo Center to Oslo North + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger very close to trip start and end points + var passengerPickup = new WgsCoordinate(59.914, 10.753); // Very close to start + var passengerDropoff = new WgsCoordinate(59.954, 10.791); // Very close to end + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_passengerAtMaxDistance_returnsTrue() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger locations at approximately 50km perpendicular distance from route + // This is at the boundary of acceptance + // Using ~0.4° offset which is roughly 45km at this latitude + var passengerPickup = new WgsCoordinate(59.920, 10.752 + 0.4); + var passengerDropoff = new WgsCoordinate(59.940, 10.772 + 0.4); + + // Should accept at boundary + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void customMaxDistance_acceptsWithinCustomDistance() { + // Custom filter with 100km max distance + var customFilter = new DistanceBasedFilter(100_000); + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger 80km perpendicular to the route (would be rejected by default 50km filter) + var passengerPickup = new WgsCoordinate(59.920, 10.752 + 0.7); + var passengerDropoff = new WgsCoordinate(59.940, 10.772 + 0.7); + + assertTrue(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void customMaxDistance_rejectsOutsideCustomDistance() { + // Custom filter with 20km max distance (stricter) + var customFilter = new DistanceBasedFilter(20_000); + + // Simple horizontal trip + var tripStart = new WgsCoordinate(59.9, 10.70); + var tripEnd = new WgsCoordinate(59.9, 10.80); + var trip = createSimpleTrip(tripStart, tripEnd); + + // Passenger ~30km perpendicular to the route + // At this latitude, 0.27° latitude ≈ 30km + var passengerPickup = new WgsCoordinate(59.9 + 0.27, 10.72); + var passengerDropoff = new WgsCoordinate(59.9 + 0.27, 10.78); + + assertFalse(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void getMaxDistanceMeters_returnsConfiguredDistance() { + var customFilter = new DistanceBasedFilter(75_000); + assertEquals(75_000, customFilter.getMaxDistanceMeters()); + } + + @Test + void defaultMaxDistance_is50km() { + assertEquals(50_000, filter.getMaxDistanceMeters()); + } + + @Test + void accepts_verticalRoute_passengerAlongRoute_returnsTrue() { + // Trip going straight north (same longitude) + var tripStart = new WgsCoordinate(59.9, 10.75); + var tripEnd = new WgsCoordinate(60.0, 10.75); + var trip = createSimpleTrip(tripStart, tripEnd); + + // Passenger also going north along the same longitude + var passengerPickup = new WgsCoordinate(59.92, 10.75); + var passengerDropoff = new WgsCoordinate(59.95, 10.75); + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_horizontalRoute_passengerAlongRoute_returnsTrue() { + // Trip going straight east (same latitude) + var tripStart = new WgsCoordinate(59.9, 10.70); + var tripEnd = new WgsCoordinate(59.9, 10.80); + var trip = createSimpleTrip(tripStart, tripEnd); + + // Passenger also going east along the same latitude + var passengerPickup = new WgsCoordinate(59.9, 10.72); + var passengerDropoff = new WgsCoordinate(59.9, 10.78); + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_tripWithMultipleStops_passengerNearMainRoute() { + // Trip with multiple stops - filter only looks at boarding to alighting line + var stop1 = createStopAt(0, LAKE_EAST); + var stop2 = createStopAt(1, LAKE_SOUTH); + var trip = createTripWithStops(LAKE_NORTH, java.util.List.of(stop1, stop2), LAKE_WEST); + + // Passenger journey near the direct line from LAKE_NORTH to LAKE_WEST + // Even though actual route goes through EAST and SOUTH + var passengerPickup = new WgsCoordinate(59.9239, 10.735); // Between NORTH and WEST + var passengerDropoff = new WgsCoordinate(59.9239, 10.720); + + // Should accept if close to the direct line (boarding to alighting) + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void accepts_sameStartEnd_passengerAtSameLocation_returnsTrue() { + // Edge case: trip starts and ends at same location (round trip) + var sameLocation = new WgsCoordinate(59.9, 10.75); + var trip = createSimpleTrip(sameLocation, sameLocation); + + // Passenger at the same location + var passengerPickup = new WgsCoordinate(59.901, 10.751); // Very close + var passengerDropoff = new WgsCoordinate(59.902, 10.752); + + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + } + + @Test + void rejects_sameStartEnd_passengerFarAway_returnsFalse() { + // Edge case: trip starts and ends at same location + var sameLocation = new WgsCoordinate(59.9, 10.75); + var trip = createSimpleTrip(sameLocation, sameLocation); + + // Passenger far away + var passengerPickup = new WgsCoordinate(60.5, 11.0); + var passengerDropoff = new WgsCoordinate(60.5, 11.1); + + assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java new file mode 100644 index 00000000000..8176552e471 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java @@ -0,0 +1,146 @@ +package org.opentripplanner.ext.carpooling.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TimeBasedFilterTest { + + private TimeBasedFilter filter; + + @BeforeEach + void setup() { + filter = new TimeBasedFilter(); + } + + @Test + void accepts_passengerRequestWithinTimeWindow_returnsTrue() { + // Trip departs at 10:00 + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests at 10:15 (15 minutes after trip departure) + var passengerRequestTime = tripDepartureTime.plusMinutes(15).toInstant(); + + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void accepts_passengerRequestExactlyAtTripDeparture_returnsTrue() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests exactly when trip departs + var passengerRequestTime = tripDepartureTime.toInstant(); + + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void accepts_passengerRequestAtWindowBoundary_returnsTrue() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests exactly 30 minutes after (at boundary) + var passengerRequestTime = tripDepartureTime.plusMinutes(30).toInstant(); + + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void accepts_passengerRequestBeforeTripDeparture_withinWindow_returnsTrue() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests 20 minutes before trip departs (within 30-min window) + var passengerRequestTime = tripDepartureTime.minusMinutes(20).toInstant(); + + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void rejects_passengerRequestTooFarInFuture_returnsFalse() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests 45 minutes after trip departs (outside 30-min window) + var passengerRequestTime = tripDepartureTime.plusMinutes(45).toInstant(); + + assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void rejects_passengerRequestTooFarInPast_returnsFalse() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests 45 minutes before trip departs (outside 30-min window) + var passengerRequestTime = tripDepartureTime.minusMinutes(45).toInstant(); + + assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void rejects_passengerRequestWayTooLate_returnsFalse() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests 2 hours after trip departs + var passengerRequestTime = tripDepartureTime.plusHours(2).toInstant(); + + assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void customTimeWindow_acceptsWithinCustomWindow() { + // Custom filter with 60-minute window + var customFilter = new TimeBasedFilter(Duration.ofMinutes(60)); + + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests 50 minutes after (within 60-min window, outside default 30-min) + var passengerRequestTime = tripDepartureTime.plusMinutes(50).toInstant(); + + assertTrue(customFilter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void customTimeWindow_rejectsOutsideCustomWindow() { + // Custom filter with 10-minute window + var customFilter = new TimeBasedFilter(Duration.ofMinutes(10)); + + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // Passenger requests 15 minutes after (outside 10-min window) + var passengerRequestTime = tripDepartureTime.plusMinutes(15).toInstant(); + + assertFalse(customFilter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + } + + @Test + void acceptsWithoutTimeParameter_alwaysReturnsTrue() { + var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); + var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); + + // When called without time parameter, should accept (with warning log) + assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST)); + } + + @Test + void getTimeWindow_returnsConfiguredWindow() { + var customFilter = new TimeBasedFilter(Duration.ofMinutes(45)); + assertEquals(Duration.ofMinutes(45), customFilter.getTimeWindow()); + } + + @Test + void defaultTimeWindow_is30Minutes() { + assertEquals(Duration.ofMinutes(30), filter.getTimeWindow()); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java new file mode 100644 index 00000000000..e887865f41c --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java @@ -0,0 +1,273 @@ +package org.opentripplanner.ext.carpooling.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; +import org.opentripplanner.framework.geometry.WgsCoordinate; + +class BeelineEstimatorTest { + + private BeelineEstimator estimator; + + @BeforeEach + void setup() { + estimator = new BeelineEstimator(); + } + + @Test + void estimateDuration_shortDistance_returnsReasonableDuration() { + // Oslo Center to Oslo East (~2.5km beeline) + Duration duration = estimator.estimateDuration(OSLO_CENTER, OSLO_EAST); + + // With default parameters (1.3 detour factor, 10 m/s speed): + // Expected: ~2500m * 1.3 / 10 = ~325 seconds = ~5.4 minutes + assertTrue(duration.getSeconds() > 240); // > 4 minutes + assertTrue(duration.getSeconds() < 480); // < 8 minutes + } + + @Test + void estimateDuration_mediumDistance_returnsReasonableDuration() { + // Oslo Center to Oslo North (~3.3km beeline) + Duration duration = estimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + + // Expected: ~3300m * 1.3 / 10 = ~429 seconds = ~7.2 minutes + assertTrue(duration.getSeconds() > 300); // > 5 minutes + assertTrue(duration.getSeconds() < 600); // < 10 minutes + } + + @Test + void estimateDuration_sameLocation_returnsZero() { + Duration duration = estimator.estimateDuration(OSLO_CENTER, OSLO_CENTER); + assertEquals(Duration.ZERO, duration); + } + + @Test + void estimateDuration_veryShortDistance_roundsDownToZero() { + // Two points very close together (~10 meters) + var point1 = new WgsCoordinate(59.9139, 10.7522); + var point2 = new WgsCoordinate(59.9140, 10.7522); + + Duration duration = estimator.estimateDuration(point1, point2); + + // ~10m * 1.3 / 10 = ~1.3 seconds, rounds to 1 + assertTrue(duration.getSeconds() <= 5); + } + + @Test + void calculateCumulativeTimes_simpleRoute_calculatesCorrectly() { + // Route: Oslo Center → Oslo East → Oslo North + List points = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); + + Duration[] times = estimator.calculateCumulativeTimes(points); + + assertEquals(3, times.length); + assertEquals(Duration.ZERO, times[0]); // Start at 0 + + // Each segment should add positive duration + assertTrue(times[1].compareTo(Duration.ZERO) > 0); + assertTrue(times[2].compareTo(times[1]) > 0); + + // Total duration should be reasonable (sum of two ~3-5km segments) + assertTrue(times[2].getSeconds() > 600); // > 10 minutes + assertTrue(times[2].getSeconds() < 1800); // < 30 minutes + } + + @Test + void calculateCumulativeTimes_singlePoint_returnsZero() { + List points = List.of(OSLO_CENTER); + + Duration[] times = estimator.calculateCumulativeTimes(points); + + assertEquals(1, times.length); + assertEquals(Duration.ZERO, times[0]); + } + + @Test + void calculateCumulativeTimes_emptyList_returnsEmptyArray() { + List points = List.of(); + + Duration[] times = estimator.calculateCumulativeTimes(points); + + assertEquals(0, times.length); + } + + @Test + void calculateCumulativeTimes_multipleStops_timesAreMonotonic() { + // Route with multiple stops + List points = List.of( + OSLO_CENTER, + OSLO_EAST, + OSLO_NORTHEAST, + OSLO_NORTH, + OSLO_NORTHWEST + ); + + Duration[] times = estimator.calculateCumulativeTimes(points); + + // Times should be strictly increasing + for (int i = 1; i < times.length; i++) { + assertTrue( + times[i].compareTo(times[i - 1]) > 0, + "Time at position " + i + " should be greater than time at position " + (i - 1) + ); + } + } + + @Test + void customDetourFactor_increasedFactor_increasesEstimate() { + var defaultEstimator = new BeelineEstimator(1.3, 10.0); + var higherDetourEstimator = new BeelineEstimator(1.5, 10.0); + + Duration defaultDuration = defaultEstimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + Duration higherDetourDuration = higherDetourEstimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + + // Higher detour factor should give longer duration + assertTrue(higherDetourDuration.compareTo(defaultDuration) > 0); + } + + @Test + void customSpeed_lowerSpeed_increasesEstimate() { + var defaultEstimator = new BeelineEstimator(1.3, 10.0); + var slowerEstimator = new BeelineEstimator(1.3, 5.0); + + Duration defaultDuration = defaultEstimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + Duration slowerDuration = slowerEstimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + + // Lower speed should give longer duration + assertTrue(slowerDuration.compareTo(defaultDuration) > 0); + // Should be approximately double + assertTrue(slowerDuration.getSeconds() > defaultDuration.getSeconds() * 1.8); + assertTrue(slowerDuration.getSeconds() < defaultDuration.getSeconds() * 2.2); + } + + @Test + void customParameters_applyCorrectly() { + // Custom: 2x detour, 20 m/s speed + var customEstimator = new BeelineEstimator(2.0, 20.0); + + // Calculate expected duration manually + double beelineDistance = SphericalDistanceLibrary.fastDistance( + OSLO_CENTER.asJtsCoordinate(), + OSLO_NORTH.asJtsCoordinate() + ); + double expectedSeconds = (beelineDistance * 2.0) / 20.0; + Duration expectedDuration = Duration.ofSeconds((long) expectedSeconds); + + Duration actualDuration = customEstimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + + // Should be very close (within 1 second due to rounding) + long diff = Math.abs(actualDuration.getSeconds() - expectedDuration.getSeconds()); + assertTrue(diff <= 1); + } + + @Test + void getDetourFactor_returnsConfiguredValue() { + assertEquals(1.3, estimator.getDetourFactor()); + + var customEstimator = new BeelineEstimator(1.5, 10.0); + assertEquals(1.5, customEstimator.getDetourFactor()); + } + + @Test + void getSpeedMps_returnsConfiguredValue() { + assertEquals(10.0, estimator.getSpeedMps()); + + var customEstimator = new BeelineEstimator(1.3, 15.0); + assertEquals(15.0, customEstimator.getSpeedMps()); + } + + @Test + void defaultDetourFactor_is1Point3() { + assertEquals(1.3, BeelineEstimator.DEFAULT_DETOUR_FACTOR); + } + + @Test + void defaultSpeed_is10MetersPerSecond() { + assertEquals(10.0, BeelineEstimator.DEFAULT_SPEED_MPS); + } + + @Test + void constructor_detourFactorLessThanOne_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> new BeelineEstimator(0.9, 10.0), + "detourFactor must be >= 1.0" + ); + } + + @Test + void constructor_detourFactorExactlyOne_accepts() { + // Minimum valid detour factor (no detour) + var estimator = new BeelineEstimator(1.0, 10.0); + assertEquals(1.0, estimator.getDetourFactor()); + } + + @Test + void constructor_zeroSpeed_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> new BeelineEstimator(1.3, 0.0), + "speedMps must be positive" + ); + } + + @Test + void constructor_negativeSpeed_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> new BeelineEstimator(1.3, -5.0), + "speedMps must be positive" + ); + } + + @Test + void estimateDuration_longDistance_scalesCorrectly() { + // Oslo to Bergen (~300km beeline) + var bergen = new WgsCoordinate(60.39, 5.32); + + Duration duration = estimator.estimateDuration(OSLO_CENTER, bergen); + + // Expected: ~300,000m * 1.3 / 10 = 39,000 seconds = 650 minutes + // This is just a sanity check - beeline is not accurate for such long distances + assertTrue(duration.getSeconds() > 30000); // > 8.3 hours + assertTrue(duration.getSeconds() < 60000); // < 16.7 hours + } + + @Test + void calculateCumulativeTimes_twoPoints_calculatesCorrectly() { + List points = List.of(OSLO_CENTER, OSLO_NORTH); + + Duration[] times = estimator.calculateCumulativeTimes(points); + + assertEquals(2, times.length); + assertEquals(Duration.ZERO, times[0]); + assertTrue(times[1].compareTo(Duration.ZERO) > 0); + } + + @Test + void estimateDuration_optimisticEstimate_lessThanActualStreetRoute() { + // Beeline estimates should be optimistic (underestimate actual travel time) + // This is important for the heuristic to work correctly + + // For urban areas, actual street routes are typically 1.3-1.5x beeline + // Our default detour factor of 1.3 is intentionally optimistic + Duration beelineEstimate = estimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); + + // Typical actual street route would be ~1.5x beeline at 10 m/s + double actualBeelineDistance = SphericalDistanceLibrary.fastDistance( + OSLO_CENTER.asJtsCoordinate(), + OSLO_NORTH.asJtsCoordinate() + ); + Duration conservativeActualTime = Duration.ofSeconds( + (long) ((actualBeelineDistance * 1.5) / 10.0) + ); + + // Our estimate should be less than or equal to a conservative actual time + assertTrue(beelineEstimate.compareTo(conservativeActualTime) <= 0); + } +} From 468fa0a18c7aa466485589d026629fa847208c36 Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 17 Oct 2025 11:46:36 +0200 Subject: [PATCH 09/40] Refactors and simplifies. --- .../ext/carpooling/CarpoolingRepository.java | 32 +- .../ext/carpooling/CarpoolingService.java | 21 + .../opentripplanner/ext/carpooling/README.md | 468 ++++++++++++++++++ .../PassengerDelayConstraints.java | 34 +- .../DirectionalCompatibilityFilter.java | 111 +---- .../filter/DistanceBasedFilter.java | 79 ++- .../ext/carpooling/filter/FilterChain.java | 47 +- .../carpooling/filter/TimeBasedFilter.java | 32 +- .../ext/carpooling/filter/TripFilter.java | 88 +--- .../internal/CarpoolItineraryMapper.java | 76 ++- .../ext/carpooling/model/CarpoolLeg.java | 16 +- .../ext/carpooling/model/CarpoolTrip.java | 144 +++++- .../routing/InsertionEvaluator.java | 357 +++++++++++++ .../carpooling/routing/InsertionPosition.java | 51 ++ .../routing/InsertionPositionFinder.java | 279 +++++++++++ .../routing/OptimalInsertionStrategy.java | 410 --------------- .../ext/carpooling/routing/RoutePoint.java | 25 - .../service/DefaultCarpoolingService.java | 177 +++++-- .../util/DirectionalCalculator.java | 126 ----- .../util/PassengerCountTimeline.java | 141 ------ .../ext/carpooling/util/RouteGeometry.java | 124 ----- .../validation/CapacityValidator.java | 52 -- .../validation/CompositeValidator.java | 57 --- .../validation/DirectionalValidator.java | 107 ---- .../validation/InsertionValidator.java | 88 ---- .../apis/gtfs/datafetchers/LegImpl.java | 2 +- .../PassengerDelayConstraintsTest.java | 15 - .../DirectionalCompatibilityFilterTest.java | 48 +- .../filter/DistanceBasedFilterTest.java | 35 +- .../filter/TimeBasedFilterTest.java | 67 +-- .../model/CarpoolTripCapacityTest.java | 125 +++++ .../routing/InsertionEvaluatorTest.java | 352 +++++++++++++ .../routing/InsertionPositionFinderTest.java | 99 ++++ .../routing/OptimalInsertionStrategyTest.java | 204 -------- .../carpooling/routing/RoutePointTest.java | 79 --- .../util/DirectionalCalculatorTest.java | 159 ------ .../util/PassengerCountTimelineTest.java | 133 ----- .../carpooling/util/RouteGeometryTest.java | 99 ---- .../validation/CapacityValidatorTest.java | 120 ----- .../validation/CompositeValidatorTest.java | 157 ------ .../validation/DirectionalValidatorTest.java | 181 ------- 41 files changed, 2269 insertions(+), 2748 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/README.md create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java index 1782954db56..a7b1ecccb1d 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java @@ -4,9 +4,39 @@ import org.opentripplanner.ext.carpooling.model.CarpoolTrip; /** - * The CarpoolingRepository interface allows for the management and retrieval of carpooling trips. + * Repository for managing carpooling trip ({@link CarpoolTrip}) data. + *

+ * This repository maintains an in-memory index of driver trips. + * + * @see CarpoolTrip for trip data model + * @see org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater for real-time updates */ public interface CarpoolingRepository { + /** + * Returns all currently carpooling trips. + *

+ * The returned collection includes all driver trips that have been added via {@link #upsertCarpoolTrip} + * and not yet removed or expired. The collection is typically used by the routing service to find + * compatible trips for passengers. + */ Collection getCarpoolTrips(); + + /** + * Inserts a new carpooling trip or updates an existing trip with the same ID. + *

+ * This method is the primary mechanism for adding driver trip data to the repository. It is + * typically called by real-time updaters when receiving trip information from external systems, + * or when passenger bookings modify trip capacity. + * + *

Validation

+ *

+ * The method does not validate trip data beyond basic null checks. It is the caller's + * responsibility to ensure the trip is valid (has stops, positive capacity, etc.). Invalid + * trips may cause routing failures later. + * + * @param trip the carpool trip to insert or update, must not be null. If a trip with the same + * ID exists, it will be completely replaced. + * @throws IllegalArgumentException if trip is null + */ void upsertCarpoolTrip(CarpoolTrip trip); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index 1243292bdac..7fafe3c850c 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -5,6 +5,27 @@ import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.error.RoutingValidationException; +/** + * Service for finding carpooling options by matching passenger requests with available driver trips. + *

+ * Carpooling enables passengers to join existing driver journeys by being picked up and dropped off + * along the driver's route. The service finds optimal insertion points for new passengers while + * respecting capacity constraints, time windows, and route deviation budgets. + * + * @see CarpoolingRepository for managing driver trip data + * @see org.opentripplanner.ext.carpooling.routing.OptimalInsertionStrategy for insertion algorithm details + */ public interface CarpoolingService { + /** + * Finds carpooling itineraries matching the passenger's routing request. + *

+ * + * @param request the routing request containing passenger origin, destination, and preferences + * @return list of carpool itineraries, sorted by quality (additional travel time), may be empty + * if no compatible trips found. Results are limited to avoid overwhelming users. + * @throws RoutingValidationException if the request is invalid (missing origin/destination, + * invalid coordinates, etc.) + * @throws IllegalArgumentException if request is null + */ List route(RouteRequest request) throws RoutingValidationException; } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/README.md b/application/src/ext/java/org/opentripplanner/ext/carpooling/README.md new file mode 100644 index 00000000000..dff8c5d4461 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/README.md @@ -0,0 +1,468 @@ +# Carpooling Extension for OpenTripPlanner + +The carpooling extension enables OpenTripPlanner to find carpool trip options by matching passenger requests with active driver journeys. Passengers can be dynamically inserted into existing driver routes at optimal pickup and dropoff points while respecting capacity constraints, timing windows, and driver deviation budgets. + +## Quick Overview + +**What it does**: Matches passengers with drivers offering their vehicle journey for ride-sharing. + +**Why it exists**: Provides flexible, demand-responsive carpooling as a complement to fixed-route transit. + +**How it works**: Three-phase algorithm (filter → pre-screen → route → validate) finds optimal passenger insertion points in driver routes using A* street routing with intelligent position pre-screening and segment caching. + +## Key Features + +- **Real-time matching**: Finds compatible carpool trips from active driver pool +- **Optimal insertion**: Computes best pickup/dropoff positions using A* street routing +- **Flexible constraints**: Respects capacity, time windows, driver deviation budgets +- **Performance optimized**: Fast filtering eliminates 70-90% of trips before routing +- **SIRI-ET integration**: Real-time trip updates from external carpooling platforms + +## Architecture + +### High-Level Flow + +``` +┌─────────────────┐ +│ Passenger │ +│ Routing Request │ +└────────┬────────┘ + │ + v +┌────────────────────────────────────────────┐ +│ DefaultCarpoolingService │ +│ │ +│ 1. Filter Phase (FilterChain) │ +│ - Capacity check │ +│ - Time window check │ +│ - Direction check │ +│ - Distance check │ +│ │ +│ 2. Insertion Phase │ +│ 2a. Position Pre-screening │ +│ (InsertionPositionFinder) │ +│ - Capacity check │ +│ - Directional check │ +│ - Beeline delay heuristic │ +│ │ +│ 2b. Routing & Selection │ +│ (OptimalInsertionStrategy) │ +│ - Route baseline segments (cached) │ +│ - Route viable positions │ +│ - Endpoint-matching segment reuse │ +│ - Select minimum additional time │ +│ │ +│ 3. Validation Phase (CompositeValidator) │ +│ - Capacity timeline check │ +│ - Directional consistency check │ +│ - Deviation budget check │ +│ │ +└────────┬───────────────────────────────────┘ + │ + v +┌────────────────────┐ +│ Itinerary Results │ +│ (CarpoolLeg) │ +└────────────────────┘ +``` + +### Package Structure + +``` +org.opentripplanner.ext.carpooling/ +├── CarpoolingService.java # Main API interface +├── CarpoolingRepository.java # Trip data management +│ +├── model/ # Domain models +│ ├── CarpoolTrip.java # Driver's journey with stops +│ ├── CarpoolStop.java # Waypoint along route +│ ├── CarpoolLeg.java # Itinerary leg for results +│ └── CarpoolTripBuilder.java # Builder for trip construction +│ +├── service/ # Service implementation +│ └── DefaultCarpoolingService.java # Main service orchestration +│ +├── filter/ # Pre-screening filters +│ ├── FilterChain.java # Composite filter +│ ├── CapacityFilter.java # Seat availability check +│ ├── TimeBasedFilter.java # Time window check +│ ├── DirectionalCompatibilityFilter.java # Direction check +│ └── DistanceBasedFilter.java # Distance check +│ +├── routing/ # Insertion optimization +│ ├── OptimalInsertionStrategy.java # Main insertion algorithm +│ ├── InsertionPositionFinder.java # Viable position pre-screening +│ ├── InsertionPosition.java # Position pair (pickup, dropoff) +│ └── InsertionCandidate.java # Result of insertion computation +│ +├── validation/ # Constraint validation +│ ├── CompositeValidator.java # Composite validator +│ ├── CapacityValidator.java # Capacity timeline check +│ └── DirectionalValidator.java # Backtracking check +│ +├── internal/ # Implementation details +│ ├── DefaultCarpoolingRepository.java # In-memory repository +│ └── CarpoolItineraryMapper.java # Maps insertions to itineraries +│ +├── updater/ # Real-time updates +│ └── SiriETCarpoolingUpdater.java # SIRI-ET message processing +│ +├── util/ # Utilities +│ ├── BeelineEstimator.java # Straight-line distance estimation +│ └── DirectionalCalculator.java # Bearing and direction calculations +│ +├── constraints/ # Constraint definitions +│ └── PassengerDelayConstraints.java # Delay limits for passengers +│ +└── configure/ # Dependency injection + └── CarpoolingModule.java # Dagger module +``` + +## Algorithm Explanation + +### Phase 1: Filtering (Fast Pre-screening) + +Filters eliminate obviously incompatible trips **without any street routing**: + +1. **CapacityFilter**: Does the vehicle have available seats? +2. **TimeBasedFilter**: Is the trip timing compatible with passenger request? +3. **DirectionalCompatibilityFilter**: Are driver and passenger heading the same direction? +4. **DistanceBasedFilter**: Is the passenger's journey within reasonable distance of driver route? + +**Performance**: O(n) where n = number of active trips. + +### Phase 2: Insertion Optimization (Finding Best Position) + +For trips that pass filtering, computes optimal pickup/dropoff positions using a two-stage approach: + +#### Stage 1: Position Pre-screening (InsertionPositionFinder) + +Fast heuristic checks eliminate impossible positions **before any A* routing**: + +``` +For each remaining trip: + 1. Generate all position combinations (pickup, dropoff) where: + - Pickup: between any two consecutive stops (1-indexed) + - Dropoff: after pickup position + + 2. For each position pair, check: + a. Capacity: Does insertion exceed vehicle capacity at any point? + b. Direction: Does insertion cause backtracking or U-turns? + c. Beeline delay: Do straight-line estimates exceed delay threshold? + + 3. Return only "viable" positions that pass all checks +``` + +**Key optimizations**: +- **Capacity validation**: Uses `CarpoolTrip.hasCapacityForInsertion()` to check entire journey range +- **Directional filtering**: Prevents insertions that deviate >90° from route bearing +- **Beeline heuristic**: Optimistic straight-line estimates eliminate positions early +- **No routing yet**: All checks use geometric calculations only + +#### Stage 2: Routing and Selection (OptimalInsertionStrategy) + +For viable positions from Stage 1, perform A* routing to find the optimal insertion: + +``` +For each trip with viable positions: + 1. Route baseline segments (driver's original route) and cache results + + 2. For each viable position: + a. Build modified route with passenger inserted + b. Route only segments with changed endpoints + c. Reuse cached segments where endpoints match exactly + d. Calculate total duration and additional time vs. baseline + e. Check passenger delay constraints + + 3. Select insertion with minimum additional time + 4. Ensure additional time ≤ driver's deviation budget +``` + +**Critical optimization - Endpoint-matching segment reuse**: +- Baseline segments are cached after first routing +- For modified routes, segments are reused **only if both endpoints match exactly** +- Endpoint matching uses `WgsCoordinate.equals()` with 7-decimal precision (~1cm) +- Only segments with changed endpoints are re-routed +- Prevents incorrect reuse when passenger insertion splits existing segments + +### Phase 3: Validation (Constraint Satisfaction) + +Ensures the proposed insertion satisfies all constraints: + +1. **CapacityValidator**: Verifies sufficient capacity throughout passenger's journey + - Tracks passenger count at each stop + - Ensures capacity never exceeds vehicle limit + +2. **DirectionalValidator**: Ensures no backtracking + - Computes bearings between consecutive stops + - Rejects if bearing changes > threshold (indicates backtracking) + +3. **Deviation Budget Check**: Ensures additional time ≤ driver's stated willingness + +**All validators must pass** for an insertion to be considered valid. + +## Usage Examples + +### Basic Carpooling Query + +```java +// Injected via Dagger +@Inject CarpoolingService carpoolingService; + +// Create routing request +RouteRequest request = new RouteRequest(); +request.setFrom(new GenericLocation(59.9, 10.7)); // Passenger pickup +request.setTo(new GenericLocation(59.95, 10.75)); // Passenger dropoff +request.setDateTime(Instant.now()); + +// Find carpool options +List carpoolOptions = carpoolingService.route(request); + +// Process results +for (Itinerary itinerary : carpoolOptions) { + // Each itinerary contains a CarpoolLeg with: + // - Pickup time and location + // - Dropoff time and location + // - Journey duration + // - Route geometry +} +``` + +### Adding Driver Trips via SIRI-ET + +Trips are typically added via the SIRI-ET updater, but can also be added programmatically: + +```java +@Inject CarpoolingRepository repository; + +// Build a trip using the builder +CarpoolTrip trip = CarpoolTrip.builder() + .withId(FeedScopedId.parse("PROVIDER:trip123")) + .withBoardingArea(boardingArea) + .withAlightingArea(alightingArea) + .withStartTime(ZonedDateTime.now()) + .withEndTime(ZonedDateTime.now().plusMinutes(35)) // 30 min journey + 5 min buffer + .withDeviationBudget(Duration.ofMinutes(5)) // Willing to deviate 5 minutes + .withAvailableSeats(3) + .withProvider("PROVIDER") + .withStops(List.of( + // Add intermediate stops if any + )) + .build(); + +// Add to repository (makes immediately available for routing) +repository.upsertCarpoolTrip(trip); +``` + +## Configuration + +The carpooling extension is a sandbox feature that must be enabled: + +```json +// router-config.json +{ + "otpFeatures": { + "CarPooling": true + } +} +``` + +### SIRI-ET Real-time Updates + +Configure the SIRI-ET updater to receive trip updates: + +```json +// router-config.json +{ + "updaters": [ + { + "type": "siri-et-carpooling-updater", + "url": "https://api.carpooling-provider.com/siri-et", + "feedId": "PROVIDER", + "frequencySec": 30 + } + ] +} +``` + +## Data Model + +### CarpoolTrip + +Represents a driver's journey offering carpool seats: + +- **id**: Unique trip identifier +- **boardingArea**: Start zone for driver journey +- **alightingArea**: End zone for driver journey +- **startTime**: When driver departs +- **endTime**: When driver arrives (includes deviation budget) +- **deviationBudget**: Extra time driver is willing to spend for passengers +- **availableSeats**: Current remaining capacity +- **stops**: Ordered list of waypoints (includes booked passenger stops) +- **provider**: Source system identifier + +### CarpoolStop + +Waypoint along a carpool route: + +- **coordinate**: Geographic location +- **sequenceNumber**: Order in route (0-indexed) +- **estimatedArrivalTime**: When driver expects to arrive +- **stopType**: PICKUP or DROPOFF +- **passengerDelta**: Change in passenger count (+1 for pickup, -1 for dropoff) + +### InsertionPosition + +Represents a viable pickup/dropoff position pair: + +- **pickupPos**: Position to insert passenger pickup (1-indexed) +- **dropoffPos**: Position to insert passenger dropoff (1-indexed) + +Note: Positions are 1-indexed to match insertion semantics (insert between existing points). + +### InsertionCandidate + +Result of finding optimal passenger insertion: + +- **trip**: The original carpool trip +- **pickupPosition**: Where to insert passenger pickup (index) +- **dropoffPosition**: Where to insert passenger dropoff (index) +- **segments**: Routed path segments for modified route +- **baselineDuration**: Original trip duration +- **totalDuration**: Modified trip duration (with passenger) +- **additionalDuration**: Extra time added (= totalDuration - baselineDuration) + +## Performance Characteristics + +### Performance Bottlenecks + +If performance degrades: +1. **Too many active trips**: Filter more aggressively +2. **Large route deviation budgets**: Increases insertion positions to test +3. **Complex street networks**: A* routing takes longer + +## Thread Safety + +All components are designed for concurrent access: + +- **CarpoolingService**: Stateless, fully thread-safe +- **CarpoolingRepository**: Uses ConcurrentHashMap for thread-safe reads/writes +- **Filters & Validators**: Stateless, fully thread-safe + +Multiple routing requests can execute concurrently without coordination. + +## Extension Points + +### Custom Filters + +Add domain-specific filters by implementing `TripFilter`: + +```java +public class CustomFilter implements TripFilter { + @Override + public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, + WgsCoordinate dropoff, Instant requestTime) { + // Custom logic + return true; + } + + @Override + public String name() { + return "CustomFilter"; + } +} + +// Add to filter chain +FilterChain chain = FilterChain.of( + new CapacityFilter(), + new TimeBasedFilter(), + new CustomFilter() +); +``` + +### Custom Validators + +Add constraint validation by implementing `InsertionValidator`: + +```java +public class CustomValidator implements InsertionValidator { + @Override + public ValidationResult validate(ValidationContext context) { + // Custom validation logic + if (violatesConstraint) { + return ValidationResult.invalid("Constraint violated"); + } + return ValidationResult.valid(); + } +} +``` + +## Testing + +### Unit Testing + +Test individual components in isolation: + +```java +@Test +void testCapacityFilter() { + var filter = new CapacityFilter(); + var trip = createTripWithSeats(2); // 2 available seats + + // Should pass - within capacity + assertTrue(filter.accepts(trip, pickup, dropoff, now())); + + var fullTrip = createTripWithSeats(0); // No seats + + // Should fail - no capacity + assertFalse(filter.accepts(fullTrip, pickup, dropoff, now())); +} +``` + +### Integration Testing + +Test full routing flow with graph: + +```java +@Test +void testCarpoolingRouting() { + // Build test graph with carpool trips + Graph graph = buildTestGraph(); + repository.upsertCarpoolTrip(testTrip); + + // Enable feature + OTPFeature.enableFeatures(Map.of(OTPFeature.CarPooling, true)); + + // Execute routing + RouteRequest request = createRequest(from, to); + List results = carpoolingService.route(request); + + // Verify + assertFalse(results.isEmpty()); + assertTrue(results.get(0).getLegs().get(0) instanceof CarpoolLeg); +} +``` + +## Troubleshooting + +### No carpool results returned + +1. **Check feature toggle**: Ensure `CarPooling` is enabled in `router-config.json` +2. **Verify trip data**: Use `repository.getCarpoolTrips()` to check active trips +3. **Check filters**: Enable DEBUG logging to see which filters reject trips +4. **Time windows**: Ensure passenger request time matches trip timing + +### Poor performance + +1. **Too many active trips**: Consider cleanup of expired trips +2. **Enable logging**: Set `org.opentripplanner.ext.carpooling` to DEBUG +3. **Profile filters**: Check which filters are rejecting trips +4. **Reduce deviation budget**: Limits insertion positions to test + +### Routing failures + +1. **Street network connectivity**: Ensure OSM data covers pickup/dropoff areas +2. **Car routing enabled**: Verify street mode CAR is allowed +3. **Check routing logs**: Look for "Routing failed" warnings +4. **Verify coordinates**: Ensure pickup/dropoff are valid coordinates +5. \ No newline at end of file diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java index 54b72f8ecf7..1b21a5cc177 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java @@ -90,7 +90,12 @@ public boolean satisfiesConstraints( originalIndex < originalCumulativeTimes.length - 1; originalIndex++ ) { - int modifiedIndex = getModifiedIndex(originalIndex, pickupPos, dropoffPos); + int modifiedIndex = + org.opentripplanner.ext.carpooling.routing.InsertionPosition.mapOriginalIndex( + originalIndex, + pickupPos, + dropoffPos + ); Duration originalTime = originalCumulativeTimes[originalIndex]; Duration modifiedTime = modifiedTimes[modifiedIndex]; @@ -117,33 +122,6 @@ public boolean satisfiesConstraints( return true; } - /** - * Maps an index in the original route to the corresponding index in the - * modified route after passenger stops have been inserted. - * - * @param originalIndex Index in original route - * @param pickupPos Position where pickup was inserted (1-indexed) - * @param dropoffPos Position where dropoff was inserted (1-indexed, in original space) - * @return Corresponding index in modified route - */ - private int getModifiedIndex(int originalIndex, int pickupPos, int dropoffPos) { - int modifiedIndex = originalIndex; - - // Account for pickup insertion - // If the original point was at or after pickupPos, it shifts by 1 - if (originalIndex >= pickupPos) { - modifiedIndex++; - } - - // Account for dropoff insertion - // After pickup insertion, check if the shifted index is at or after dropoffPos - if (modifiedIndex >= dropoffPos) { - modifiedIndex++; - } - - return modifiedIndex; - } - /** * Gets the configured maximum delay. * diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java index 5973d1bd305..5a9929b81a2 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java @@ -1,11 +1,8 @@ package org.opentripplanner.ext.carpooling.filter; -import java.util.ArrayList; import java.util.List; -import org.opentripplanner.ext.carpooling.model.CarpoolStop; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; -import org.opentripplanner.ext.carpooling.util.RouteGeometry; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,9 +14,6 @@ * passengers are going in generally the same direction. Uses optimized segment-based * analysis to handle routes that take detours (e.g., driving around a lake). *

- * Performance Optimization: Only checks individual segments and the - * full route (O(n) complexity) rather than all possible segment ranges (O(n²)). - * This is sufficient for filtering while maintaining accuracy. */ public class DirectionalCompatibilityFilter implements TripFilter { @@ -32,28 +26,13 @@ public class DirectionalCompatibilityFilter implements TripFilter { public static final double DEFAULT_BEARING_TOLERANCE_DEGREES = 60.0; private final double bearingToleranceDegrees; - private final double corridorToleranceDegrees; public DirectionalCompatibilityFilter() { - this(DEFAULT_BEARING_TOLERANCE_DEGREES, RouteGeometry.DEFAULT_CORRIDOR_TOLERANCE_DEGREES); + this(DEFAULT_BEARING_TOLERANCE_DEGREES); } public DirectionalCompatibilityFilter(double bearingToleranceDegrees) { - this(bearingToleranceDegrees, RouteGeometry.DEFAULT_CORRIDOR_TOLERANCE_DEGREES); - } - - /** - * Creates a filter with custom bearing and corridor tolerances. - * - * @param bearingToleranceDegrees Maximum bearing difference (in degrees) - * @param corridorToleranceDegrees Maximum distance from route corridor (in degrees, ~1° = 111km) - */ - public DirectionalCompatibilityFilter( - double bearingToleranceDegrees, - double corridorToleranceDegrees - ) { this.bearingToleranceDegrees = bearingToleranceDegrees; - this.corridorToleranceDegrees = corridorToleranceDegrees; } @Override @@ -62,39 +41,20 @@ public boolean accepts( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff ) { - // Build route points list - List routePoints = buildRoutePoints(trip); + List routePoints = trip.routePoints(); if (routePoints.size() < 2) { LOG.warn("Trip {} has fewer than 2 route points, rejecting", trip.getId()); return false; } - // Calculate passenger journey bearing double passengerBearing = DirectionalCalculator.calculateBearing( passengerPickup, passengerDropoff ); - // OPTIMIZATION: Instead of checking all O(n²) segment ranges, - // only check: - // 1. Individual segments (O(n)) - catches most compatible trips - // 2. Full route - handles end-to-end compatibility - - // Check individual segments for (int i = 0; i < routePoints.size() - 1; i++) { - if ( - isSegmentCompatible( - routePoints.get(i), - routePoints.get(i + 1), - passengerBearing, - passengerPickup, - passengerDropoff, - i, - i + 1, - routePoints - ) - ) { + if (isSegmentCompatible(routePoints.get(i), routePoints.get(i + 1), passengerBearing)) { LOG.debug( "Trip {} accepted: passenger journey aligns with segment {} ({} to {})", trip.getId(), @@ -111,12 +71,7 @@ public boolean accepts( isSegmentCompatible( routePoints.get(0), routePoints.get(routePoints.size() - 1), - passengerBearing, - passengerPickup, - passengerDropoff, - 0, - routePoints.size() - 1, - routePoints + passengerBearing ) ) { LOG.debug( @@ -137,66 +92,23 @@ public boolean accepts( } /** - * Builds the list of route points (boarding → stops → alighting). - */ - private List buildRoutePoints(CarpoolTrip trip) { - List points = new ArrayList<>(); - - // Add boarding area - points.add(trip.boardingArea().getCoordinate()); - - // Add existing stops - for (CarpoolStop stop : trip.stops()) { - points.add(stop.getCoordinate()); - } - - // Add alighting area - points.add(trip.alightingArea().getCoordinate()); - - return points; - } - - /** - * Checks if a segment is compatible with the passenger journey. + * Checks if a segment is directionally compatible with the passenger journey. * * @param segmentStart Start coordinate of the segment * @param segmentEnd End coordinate of the segment * @param passengerBearing Bearing of passenger journey - * @param passengerPickup Passenger pickup location - * @param passengerDropoff Passenger dropoff location - * @param startIdx Start index in route points (for corridor calculation) - * @param endIdx End index in route points (for corridor calculation) - * @param allRoutePoints All route points (for corridor calculation) - * @return true if segment is directionally compatible and within corridor + * @return true if segment bearing is within tolerance of passenger bearing */ private boolean isSegmentCompatible( WgsCoordinate segmentStart, WgsCoordinate segmentEnd, - double passengerBearing, - WgsCoordinate passengerPickup, - WgsCoordinate passengerDropoff, - int startIdx, - int endIdx, - List allRoutePoints + double passengerBearing ) { - // Calculate segment bearing double segmentBearing = DirectionalCalculator.calculateBearing(segmentStart, segmentEnd); - // Check directional compatibility double bearingDiff = DirectionalCalculator.bearingDifference(segmentBearing, passengerBearing); - if (bearingDiff <= bearingToleranceDegrees) { - // Also verify that pickup/dropoff are within the route corridor - List segmentPoints = allRoutePoints.subList(startIdx, endIdx + 1); - return RouteGeometry.areBothWithinCorridor( - segmentPoints, - passengerPickup, - passengerDropoff, - corridorToleranceDegrees - ); - } - - return false; + return bearingDiff <= bearingToleranceDegrees; } /** @@ -205,11 +117,4 @@ private boolean isSegmentCompatible( public double getBearingToleranceDegrees() { return bearingToleranceDegrees; } - - /** - * Gets the configured corridor tolerance in degrees. - */ - public double getCorridorToleranceDegrees() { - return corridorToleranceDegrees; - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java index c3254cfb3c6..3fd01bf2591 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java @@ -1,5 +1,6 @@ package org.opentripplanner.ext.carpooling.filter; +import java.util.List; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; import org.opentripplanner.framework.geometry.WgsCoordinate; @@ -10,10 +11,10 @@ * Filters trips based on geographic proximity to the passenger journey. *

* Checks if the passenger's pickup and dropoff locations are both within - * a reasonable distance from the driver's main route (the direct line from - * trip boarding to alighting). This allows passengers to join trips where they - * share a segment of the driver's journey, while rejecting passengers whose - * journey is far off the driver's direct path. + * a reasonable distance from the driver's route. The filter considers all + * segments of the driver's route (including intermediate stops), allowing + * passengers to join trips where they share a segment of the driver's journey, + * while rejecting passengers whose journey is far off any part of the driver's path. */ public class DistanceBasedFilter implements TripFilter { @@ -21,7 +22,7 @@ public class DistanceBasedFilter implements TripFilter { /** * Default maximum distance: 50km. - * If both trip start and end are more than this distance from + * If all segments of the trip's route are more than this distance from * both passenger pickup and dropoff, the trip is rejected. */ public static final double DEFAULT_MAX_DISTANCE_METERS = 50_000; @@ -42,31 +43,55 @@ public boolean accepts( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff ) { - WgsCoordinate tripStart = trip.boardingArea().getCoordinate(); - WgsCoordinate tripEnd = trip.alightingArea().getCoordinate(); - - // Calculate distance from passenger pickup to the driver's main route - double pickupDistanceToRoute = distanceToLineSegment(passengerPickup, tripStart, tripEnd); - - // Calculate distance from passenger dropoff to the driver's main route - double dropoffDistanceToRoute = distanceToLineSegment(passengerDropoff, tripStart, tripEnd); - - // Accept only if BOTH passenger locations are within threshold of the driver's route - boolean acceptable = - pickupDistanceToRoute <= maxDistanceMeters && dropoffDistanceToRoute <= maxDistanceMeters; - - if (!acceptable) { - LOG.debug( - "Trip {} rejected by distance filter: passenger journey too far from trip route. " + - "Pickup distance: {:.0f}m, Dropoff distance: {:.0f}m (max: {:.0f}m)", - trip.getId(), - pickupDistanceToRoute, - dropoffDistanceToRoute, - maxDistanceMeters + List routePoints = trip.routePoints(); + + if (routePoints.size() < 2) { + LOG.warn("Trip {} has fewer than 2 route points, rejecting", trip.getId()); + return false; + } + + // Check each segment of the route + for (int i = 0; i < routePoints.size() - 1; i++) { + WgsCoordinate segmentStart = routePoints.get(i); + WgsCoordinate segmentEnd = routePoints.get(i + 1); + + double pickupDistanceToSegment = distanceToLineSegment( + passengerPickup, + segmentStart, + segmentEnd ); + double dropoffDistanceToSegment = distanceToLineSegment( + passengerDropoff, + segmentStart, + segmentEnd + ); + + // Accept if either passenger location is within threshold of this segment + if ( + pickupDistanceToSegment <= maxDistanceMeters || + dropoffDistanceToSegment <= maxDistanceMeters + ) { + LOG.debug( + "Trip {} accepted by distance filter: passenger journey close to segment {} ({} to {}). " + + "Pickup distance: {:.0f}m, Dropoff distance: {:.0f}m (max: {:.0f}m)", + trip.getId(), + i, + segmentStart, + segmentEnd, + pickupDistanceToSegment, + dropoffDistanceToSegment, + maxDistanceMeters + ); + return true; + } } - return acceptable; + LOG.debug( + "Trip {} rejected by distance filter: passenger journey too far from all route segments (max: {:.0f}m)", + trip.getId(), + maxDistanceMeters + ); + return false; } /** diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java index a5ad2ea444e..5cdc9a3b1e9 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java @@ -1,8 +1,7 @@ package org.opentripplanner.ext.carpooling.filter; +import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.framework.geometry.WgsCoordinate; @@ -24,11 +23,7 @@ public class FilterChain implements TripFilter { private final List filters; public FilterChain(List filters) { - this.filters = new ArrayList<>(filters); - } - - public FilterChain(TripFilter... filters) { - this(Arrays.asList(filters)); + this.filters = filters; } /** @@ -39,10 +34,12 @@ public FilterChain(TripFilter... filters) { */ public static FilterChain standard() { return new FilterChain( - new CapacityFilter(), // Fastest: O(1) - new TimeBasedFilter(), // Very fast: O(1) - new DistanceBasedFilter(), // Fast: O(1) with 4 distance calculations - new DirectionalCompatibilityFilter() // Medium: O(n) segments + List.of( + new CapacityFilter(), // Fastest: O(1) + new TimeBasedFilter(), // Very fast: O(1) + new DistanceBasedFilter(), // Fast: O(1) with 4 distance calculations + new DirectionalCompatibilityFilter() // Medium: O(n) segments + ) ); } @@ -65,28 +62,22 @@ public boolean accepts( CarpoolTrip trip, WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff, - Instant passengerDepartureTime + Instant passengerDepartureTime, + Duration searchWindow ) { for (TripFilter filter : filters) { - if (!filter.accepts(trip, passengerPickup, passengerDropoff, passengerDepartureTime)) { + if ( + !filter.accepts( + trip, + passengerPickup, + passengerDropoff, + passengerDepartureTime, + searchWindow + ) + ) { return false; // Short-circuit: filter rejected the trip } } return true; // All filters passed } - - /** - * Adds a filter to the chain. - */ - public FilterChain add(TripFilter filter) { - filters.add(filter); - return this; - } - - /** - * Gets the number of filters in the chain. - */ - public int size() { - return filters.size(); - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java index a460240f14a..b05f325e95c 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java @@ -18,22 +18,6 @@ public class TimeBasedFilter implements TripFilter { private static final Logger LOG = LoggerFactory.getLogger(TimeBasedFilter.class); - /** - * Default time window: ±30 minutes from requested time. - * Trips departing outside this window are rejected. - */ - public static final Duration DEFAULT_TIME_WINDOW = Duration.ofMinutes(30); - - private final Duration timeWindow; - - public TimeBasedFilter() { - this(DEFAULT_TIME_WINDOW); - } - - public TimeBasedFilter(Duration timeWindow) { - this.timeWindow = timeWindow; - } - @Override public boolean accepts( CarpoolTrip trip, @@ -43,7 +27,7 @@ public boolean accepts( // Cannot filter without time information LOG.warn( "TimeBasedFilter called without time parameter - accepting all trips. " + - "Use accepts(..., Instant) instead." + "Use accepts(..., Instant, Duration) instead." ); return true; } @@ -53,7 +37,8 @@ public boolean accepts( CarpoolTrip trip, WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff, - Instant passengerDepartureTime + Instant passengerDepartureTime, + Duration searchWindow ) { Instant tripStartTime = trip.startTime().toInstant(); @@ -61,7 +46,7 @@ public boolean accepts( Duration timeDiff = Duration.between(tripStartTime, passengerDepartureTime).abs(); // Check if within time window - boolean withinWindow = timeDiff.compareTo(timeWindow) <= 0; + boolean withinWindow = timeDiff.compareTo(searchWindow) <= 0; if (!withinWindow) { LOG.debug( @@ -70,17 +55,10 @@ public boolean accepts( trip.startTime(), passengerDepartureTime, timeDiff, - timeWindow + searchWindow ); } return withinWindow; } - - /** - * Gets the configured time window. - */ - public Duration getTimeWindow() { - return timeWindow; - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java index ae298c2582a..0f7e3bae30d 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java @@ -1,5 +1,6 @@ package org.opentripplanner.ext.carpooling.filter; +import java.time.Duration; import java.time.Instant; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.framework.geometry.WgsCoordinate; @@ -32,98 +33,17 @@ public interface TripFilter { * @param passengerPickup Passenger's pickup location * @param passengerDropoff Passenger's dropoff location * @param passengerDepartureTime Passenger's requested departure time + * @param searchWindow Time window around the requested departure time * @return true if the trip passes the filter, false otherwise */ default boolean accepts( CarpoolTrip trip, WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff, - Instant passengerDepartureTime + Instant passengerDepartureTime, + Duration searchWindow ) { // Default: ignore time and delegate to coordinate-only method return accepts(trip, passengerPickup, passengerDropoff); } - - /** - * Returns a filter that always accepts all trips. - */ - static TripFilter acceptAll() { - return (trip, pickup, dropoff) -> true; - } - - /** - * Returns a filter that combines this filter with another using AND logic. - */ - default TripFilter and(TripFilter other) { - return new TripFilter() { - @Override - public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, WgsCoordinate dropoff) { - return ( - TripFilter.this.accepts(trip, pickup, dropoff) && other.accepts(trip, pickup, dropoff) - ); - } - - @Override - public boolean accepts( - CarpoolTrip trip, - WgsCoordinate pickup, - WgsCoordinate dropoff, - Instant time - ) { - return ( - TripFilter.this.accepts(trip, pickup, dropoff, time) && - other.accepts(trip, pickup, dropoff, time) - ); - } - }; - } - - /** - * Returns a filter that combines this filter with another using OR logic. - */ - default TripFilter or(TripFilter other) { - return new TripFilter() { - @Override - public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, WgsCoordinate dropoff) { - return ( - TripFilter.this.accepts(trip, pickup, dropoff) || other.accepts(trip, pickup, dropoff) - ); - } - - @Override - public boolean accepts( - CarpoolTrip trip, - WgsCoordinate pickup, - WgsCoordinate dropoff, - Instant time - ) { - return ( - TripFilter.this.accepts(trip, pickup, dropoff, time) || - other.accepts(trip, pickup, dropoff, time) - ); - } - }; - } - - /** - * Returns a filter that negates this filter. - */ - default TripFilter negate() { - return new TripFilter() { - @Override - public boolean accepts(CarpoolTrip trip, WgsCoordinate pickup, WgsCoordinate dropoff) { - return !TripFilter.this.accepts(trip, pickup, dropoff); - } - - @Override - public boolean accepts( - CarpoolTrip trip, - WgsCoordinate pickup, - WgsCoordinate dropoff, - Instant time - ) { - return !TripFilter.this.accepts(trip, pickup, dropoff, time); - } - }; - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java index 8f329961762..66e2ec4526a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -14,11 +14,83 @@ import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; +/** + * Maps carpooling insertion candidates to OTP itineraries for API responses. + *

+ * This mapper bridges between the carpooling domain model ({@link InsertionCandidate}) and + * OTP's standard itinerary model ({@link Itinerary}). It extracts the passenger's journey + * portion from the complete driver route and constructs an itinerary with timing, geometry, + * and cost information. + * + *

Mapping Strategy

+ *

+ * An {@link InsertionCandidate} contains: + *

    + *
  • Pickup segments: Driver's route from start to passenger pickup
  • + *
  • Shared segments: Passenger's journey from pickup to dropoff
  • + *
  • Dropoff segments: Driver's route from dropoff to end
  • + *
+ *

+ * This mapper focuses on the shared segments, which represent the passenger's + * actual carpool ride. The pickup segments are used only to calculate when the driver arrives + * at the pickup location. + * + *

Time Calculation

+ *

+ * The passenger's start time is the later of: + *

    + *
  1. The passenger's requested departure time
  2. + *
  3. When the driver arrives at the pickup location
  4. + *
+ *

+ * This ensures the itinerary reflects realistic timing: passengers can't board before the + * driver arrives, but they also won't board earlier than they wanted to depart. + * + *

Geometry and Cost

+ *

+ * The itinerary includes: + *

    + *
  • Geometry: Concatenated line strings from all shared route edges
  • + *
  • Distance: Sum of all shared segment edge distances
  • + *
  • Generalized cost: A* path weight from routing (time + penalties)
  • + *
+ * + *

Package Location

+ *

+ * This class is in the {@code internal} package because it's an implementation detail of + * the carpooling service. API consumers interact with {@link Itinerary} objects, not this mapper. + * + * @see InsertionCandidate for the source data structure + * @see CarpoolLeg for the carpool-specific leg type + * @see Itinerary for the OTP itinerary model + */ public class CarpoolItineraryMapper { /** - * Creates an itinerary from an insertion candidate (refactored version). - * Works with the new InsertionCandidate type from the refactored routing system. + * Converts an insertion candidate into an OTP itinerary representing the passenger's journey. + *

+ * Extracts the passenger's portion of the journey (shared segments) and constructs an itinerary + * with accurate timing, geometry, and cost information. The resulting itinerary contains a + * single {@link CarpoolLeg} representing the ride from pickup to dropoff. + * + *

Time Calculation Details

+ *

+ * The method calculates three key times: + *

    + *
  1. Driver pickup arrival: Driver's start time + pickup segment durations
  2. + *
  3. Passenger start: max(requested time, driver arrival time)
  4. + *
  5. Passenger end: start time + shared segment durations
  6. + *
+ * + *

Null Return Cases

+ *

+ * Returns {@code null} if the candidate has no shared segments, which should never happen + * for valid insertion candidates but serves as a safety check. + * + * @param request the original routing request containing passenger preferences and timing + * @param candidate the insertion candidate containing route segments and trip details + * @return an itinerary with a single carpool leg, or null if shared segments are empty + * (should not occur for valid candidates) */ public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) { // Get shared route segments (passenger pickup to dropoff) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java index bcd5cd82121..5521abaf0d0 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java @@ -136,6 +136,7 @@ public boolean overlapInTime(Leg other) { return Leg.super.overlapInTime(other); } + @Nullable @Override public Agency agency() { return null; @@ -147,6 +148,7 @@ public Operator operator() { return null; } + @Nullable @Override public Route route() { return null; @@ -158,6 +160,7 @@ public TripOnServiceDate tripOnServiceDate() { return Leg.super.tripOnServiceDate(); } + @Nullable @Override public Accessibility tripWheelchairAccessibility() { // TODO CARPOOLING @@ -232,16 +235,19 @@ public int agencyTimeZoneOffset() { return Leg.super.agencyTimeZoneOffset(); } + @Nullable @Override public Integer routeType() { return null; } + @Nullable @Override public I18NString headsign() { return null; } + @Nullable @Override public LocalDate serviceDate() { // TODO CARPOOLING @@ -295,24 +301,28 @@ public Set listTransitAlerts() { return transitAlerts; } + @Nullable @Override public PickDrop boardRule() { // TODO CARPOOLING return null; } + @Nullable @Override public PickDrop alightRule() { // TODO CARPOOLING return null; } + @Nullable @Override public BookingInfo dropOffBookingInfo() { // TODO CARPOOLING return null; } + @Nullable @Override public BookingInfo pickupBookingInfo() { // TODO CARPOOLING @@ -331,16 +341,18 @@ public ConstrainedTransfer transferToNextLeg() { return Leg.super.transferToNextLeg(); } + @Nullable @Override public Integer boardStopPosInPattern() { // TODO CARPOOLING - return 0; + return null; } + @Nullable @Override public Integer alightStopPosInPattern() { // TODO CARPOOLING - return 1; + return null; } @Nullable diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 443efc220d4..501b242125a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -2,18 +2,58 @@ import java.time.Duration; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import javax.annotation.Nullable; +import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.framework.LogInfo; import org.opentripplanner.transit.model.framework.TransitBuilder; import org.opentripplanner.transit.model.site.AreaStop; /** - * A carpool trip is defined by boarding and alighting areas, a start time, and a sequence of stops - * where passengers can be picked up or dropped off. - * It is created from SIRI ET messages that contain the necessary identifiers and trip information. + * Represents a driver's carpool journey with planned route, timing, and passenger capacity. + *

+ * A carpool trip models a driver offering their vehicle journey for passengers to join. It includes + * the driver's planned route as a sequence of stops, available seating capacity, and timing + * constraints including a deviation budget that allows the driver to slightly adjust their route + * to accommodate passengers. + * + *

Core Concepts

+ *
    + *
  • Boarding/Alighting Areas: Start and end zones for the driver's journey
  • + *
  • Stops: Ordered sequence of waypoints along the route where passengers + * can be picked up or dropped off. Stops are dynamically updated as bookings occur.
  • + *
  • Deviation Budget: Maximum additional time the driver is willing to spend + * to pick up/drop off passengers (e.g., 5 minutes). This represents the driver's flexibility.
  • + *
  • Available Seats: Current passenger capacity remaining in the vehicle
  • + *
+ * + *

Data Source

+ *

+ * Trips are typically created from SIRI-ET messages provided by external carpooling platforms. + * The platform manages driver registrations, trip creation, and real-time updates as passengers + * book or cancel rides. + * + *

Immutability

+ *

+ * CarpoolTrip instances are immutable. Updates to trip state (e.g., adding a booked passenger) + * require creating a new trip instance via {@link CarpoolTripBuilder} and upserting it to the + * {@link org.opentripplanner.ext.carpooling.CarpoolingRepository}. + * + *

Usage in Routing

+ *

+ * The routing algorithm uses trips to find compatible matches for passenger requests: + *

    + *
  1. Filters check basic compatibility (capacity, timing, direction)
  2. + *
  3. Insertion strategy finds optimal pickup/dropoff positions along the route
  4. + *
  5. Validators ensure constraints (capacity timeline, deviation budget) are satisfied
  6. + *
+ * + * @see CarpoolStop for individual stop details + * @see CarpoolTripBuilder for constructing trip instances + * @see org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater for trip updates */ public class CarpoolTrip extends AbstractTransitEntity @@ -74,16 +114,104 @@ public int availableSeats() { } /** - * @return An immutable list of stops along the carpool route, ordered by sequence + * Returns the ordered sequence of stops along the carpool route. + *

+ * Stops include both the driver's originally planned stops and any dynamically added stops + * for passenger pickups and dropoffs. The list is ordered by sequence number, representing + * the order in which stops are visited along the route. + * + * @return an immutable list of stops along the carpool route, ordered by sequence number, + * never null but may be empty for trips with no intermediate stops */ public List stops() { return stops; } - public Duration tripDuration() { - // Since the endTime is set by the driver at creation, we subtract the deviationBudget to get the - // actual trip duration. - return Duration.between(startTime, endTime).minus(deviationBudget); + /** + * Builds the full list of route points including boarding area, all stops, and alighting area. + *

+ * This list represents the complete path of the carpool trip, useful for distance and + * direction calculations during filtering and matching. + * + * @return a list of coordinates representing the full route of the trip + */ + public List routePoints() { + List points = new ArrayList<>(); + + points.add(boardingArea().getCoordinate()); + + for (CarpoolStop stop : stops()) { + points.add(stop.getCoordinate()); + } + + points.add(alightingArea().getCoordinate()); + + return points; + } + + /** + * Calculates the number of passengers in the vehicle after visiting the specified position. + *

+ * Position semantics: + * - Position 0: Boarding area (before any stops) → 0 passengers + * - Position N: After Nth stop → cumulative passenger delta up to stop N + * - Position beyond all stops: Alighting area → 0 passengers + * + * @param position The position index (0 = boarding, 1 = after first stop, etc.) + * @return Number of passengers after this position + * @throws IllegalArgumentException if position is negative + */ + public int getPassengerCountAtPosition(int position) { + if (position < 0) { + throw new IllegalArgumentException("Position must be non-negative, got: " + position); + } + + if (position == 0) { + return 0; + } + + if (position > stops.size()) { + return 0; + } + + // Accumulate passenger deltas up to this position + int count = 0; + for (int i = 0; i < position && i < stops.size(); i++) { + count += stops.get(i).getPassengerDelta(); + } + + return count; + } + + /** + * Checks if there's capacity to add passengers throughout a range of positions. + *

+ * This validates that adding passengers won't exceed vehicle capacity at any point + * between pickup and dropoff positions. + * + * @param pickupPosition The pickup position (1-indexed, inclusive) + * @param dropoffPosition The dropoff position (1-indexed, exclusive) + * @param additionalPassengers Number of passengers to add (typically 1) + * @return true if capacity is available throughout the entire range, false otherwise + */ + public boolean hasCapacityForInsertion( + int pickupPosition, + int dropoffPosition, + int additionalPassengers + ) { + int pickupPassengers = getPassengerCountAtPosition(pickupPosition - 1); + if (pickupPassengers + additionalPassengers > availableSeats) { + return false; + } + + for (int pos = pickupPosition; pos < dropoffPosition; pos++) { + int currentPassengers = getPassengerCountAtPosition(pos); + if (currentPassengers + additionalPassengers > availableSeats) { + return false; + } + } + + return true; } @Nullable diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java new file mode 100644 index 00000000000..9aec11e63d5 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java @@ -0,0 +1,357 @@ +package org.opentripplanner.ext.carpooling.routing; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Evaluates pre-filtered insertion positions using A* routing. + *

+ * This class is a pure evaluator that takes positions identified by heuristic + * filtering and evaluates them using expensive A* street routing. It selects + * the insertion that minimizes additional travel time while satisfying + * passenger delay constraints. + *

+ * This follows the established OTP pattern of separating candidate generation + * from evaluation, similar to {@code TransferGenerator} and {@code OptimizePathDomainService}. + */ +public class InsertionEvaluator { + + private static final Logger LOG = LoggerFactory.getLogger(InsertionEvaluator.class); + + private final RoutingFunction routingFunction; + private final PassengerDelayConstraints delayConstraints; + + /** + * Creates an evaluator with the specified routing function and delay constraints. + * + * @param routingFunction Function that performs A* routing between coordinates + * @param delayConstraints Constraints for acceptable passenger delays + */ + public InsertionEvaluator( + RoutingFunction routingFunction, + PassengerDelayConstraints delayConstraints + ) { + this.routingFunction = routingFunction; + this.delayConstraints = delayConstraints; + } + + /** + * Routes all baseline segments and caches the results. + * + * @return Array of routed segments, or null if any segment fails to route + */ + @SuppressWarnings("unchecked") + private GraphPath[] routeBaselineSegments(List routePoints) { + GraphPath[] segments = new GraphPath[routePoints.size() - 1]; + + for (int i = 0; i < routePoints.size() - 1; i++) { + var fromCoord = routePoints.get(i); + var toCoord = routePoints.get(i + 1); + GenericLocation from = GenericLocation.fromCoordinate( + fromCoord.latitude(), + fromCoord.longitude() + ); + GenericLocation to = GenericLocation.fromCoordinate(toCoord.latitude(), toCoord.longitude()); + + GraphPath segment = routingFunction.route(from, to); + if (segment == null) { + LOG.debug("Baseline routing failed for segment {} → {}", i, i + 1); + return null; + } + + segments[i] = segment; + } + + return segments; + } + + /** + * Calculates cumulative durations from pre-routed segments. + */ + private Duration[] calculateCumulativeTimes(GraphPath[] segments) { + Duration[] cumulativeTimes = new Duration[segments.length + 1]; + cumulativeTimes[0] = Duration.ZERO; + + for (int i = 0; i < segments.length; i++) { + Duration segmentDuration = Duration.between( + segments[i].states.getFirst().getTime(), + segments[i].states.getLast().getTime() + ); + cumulativeTimes[i + 1] = cumulativeTimes[i].plus(segmentDuration); + } + + return cumulativeTimes; + } + + /** + * Evaluates pre-filtered insertion positions using A* routing. + *

+ * This method assumes the provided positions have already passed heuristic + * validation (capacity, direction, beeline delay). It performs expensive + * A* routing for each position and selects the one with minimum additional + * duration that satisfies delay constraints. + * + * @param trip The carpool trip + * @param viablePositions Positions that passed heuristic checks (from InsertionPositionFinder) + * @param passengerPickup Passenger's pickup location + * @param passengerDropoff Passenger's dropoff location + * @return The best insertion candidate, or null if none are viable after routing + */ + @Nullable + public InsertionCandidate findBestInsertion( + CarpoolTrip trip, + List viablePositions, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + GraphPath[] baselineSegments = routeBaselineSegments(trip.routePoints()); + if (baselineSegments == null) { + LOG.warn("Could not route baseline for trip {}", trip.getId()); + return null; + } + + Duration[] cumulativeTimes = calculateCumulativeTimes(baselineSegments); + + InsertionCandidate bestCandidate = null; + Duration minAdditionalDuration = Duration.ofDays(1); + Duration baselineDuration = cumulativeTimes[cumulativeTimes.length - 1]; + + for (InsertionPosition position : viablePositions) { + InsertionCandidate candidate = evaluateInsertion( + trip, + position.pickupPos(), + position.dropoffPos(), + passengerPickup, + passengerDropoff, + baselineSegments, + cumulativeTimes, + baselineDuration + ); + + if (candidate == null) { + continue; + } + + Duration additionalDuration = candidate.additionalDuration(); + + // Check if this is the best so far and within deviation budget + if ( + additionalDuration.compareTo(minAdditionalDuration) < 0 && + additionalDuration.compareTo(trip.deviationBudget()) <= 0 + ) { + minAdditionalDuration = additionalDuration; + bestCandidate = candidate; + LOG.debug( + "New best insertion: pickup@{}, dropoff@{}, additional={}s", + position.pickupPos(), + position.dropoffPos(), + additionalDuration.getSeconds() + ); + } + } + + return bestCandidate; + } + + /** + * Evaluates a specific insertion configuration. + * Reuses cached baseline segments and only routes new segments involving the passenger. + */ + private InsertionCandidate evaluateInsertion( + CarpoolTrip trip, + int pickupPos, + int dropoffPos, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + GraphPath[] baselineSegments, + Duration[] originalCumulativeTimes, + Duration baselineDuration + ) { + // Build modified route segments by reusing cached baseline segments + List> modifiedSegments = buildModifiedSegments( + trip.routePoints(), + baselineSegments, + pickupPos, + dropoffPos, + passengerPickup, + passengerDropoff + ); + + if (modifiedSegments == null) { + return null; // Routing failed for new segments + } + + // Calculate total duration + Duration totalDuration = Duration.ZERO; + for (GraphPath segment : modifiedSegments) { + totalDuration = totalDuration.plus( + Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) + ); + } + + // Check passenger delay constraints + if ( + !delayConstraints.satisfiesConstraints( + originalCumulativeTimes, + modifiedSegments, + pickupPos, + dropoffPos + ) + ) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected by delay constraints", + pickupPos, + dropoffPos + ); + return null; + } + + return new InsertionCandidate( + trip, + pickupPos, + dropoffPos, + modifiedSegments, + baselineDuration, + totalDuration + ); + } + + /** + * Builds modified route segments by reusing cached baseline segments where possible + * and only routing new segments that involve the passenger. + * + *

This is the key optimization: instead of routing ALL segments again, + * we only route segments that changed due to passenger insertion. + * + *

Segments are reused when both endpoints match between baseline and modified routes. + * Endpoint matching uses {@link WgsCoordinate#equals()} which compares coordinates with + * 7-decimal precision (~1cm tolerance). + * + * @param originalPoints Route points before passenger insertion + * @param baselineSegments Pre-routed segments for baseline route + * @param pickupPos Passenger pickup position (1-indexed) + * @param dropoffPos Passenger dropoff position (1-indexed) + * @param passengerPickup Passenger's pickup coordinate + * @param passengerDropoff Passenger's dropoff coordinate + * @return List of segments for modified route, or null if routing fails + */ + private List> buildModifiedSegments( + List originalPoints, + GraphPath[] baselineSegments, + int pickupPos, + int dropoffPos, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + List> segments = new ArrayList<>(); + + // Build modified point list + List modifiedPoints = new ArrayList<>(originalPoints); + modifiedPoints.add(pickupPos, passengerPickup); + modifiedPoints.add(dropoffPos, passengerDropoff); + + // For each segment in the modified route: + // - Reuse baseline segment if it didn't change + // - Route new segment if it involves passenger stops + for (int i = 0; i < modifiedPoints.size() - 1; i++) { + GraphPath segment; + + // Check if this segment can be reused from baseline + int baselineIndex = getBaselineSegmentIndex(i, originalPoints, modifiedPoints); + if (baselineIndex >= 0 && baselineIndex < baselineSegments.length) { + // This segment is unchanged - reuse it! + segment = baselineSegments[baselineIndex]; + LOG.trace("Reusing baseline segment {} for modified position {}", baselineIndex, i); + } else { + // This segment involves passenger - route it + var fromCoord = modifiedPoints.get(i); + var toCoord = modifiedPoints.get(i + 1); + GenericLocation from = GenericLocation.fromCoordinate( + fromCoord.latitude(), + fromCoord.longitude() + ); + GenericLocation to = GenericLocation.fromCoordinate( + toCoord.latitude(), + toCoord.longitude() + ); + + segment = routingFunction.route(from, to); + if (segment == null) { + LOG.trace("Routing failed for new segment {} → {}", i, i + 1); + return null; + } + LOG.trace("Routed new segment for modified position {}", i); + } + + segments.add(segment); + } + + return segments; + } + + /** + * Maps a modified route segment index to the corresponding baseline segment index. + * Returns -1 if the segment cannot be reused (endpoints don't match). + * + *

A baseline segment can only be reused if BOTH endpoints match exactly between + * the baseline and modified routes. This ensures we don't reuse a segment whose + * endpoints have changed due to passenger insertion. + * + * @param modifiedIndex Index in modified route (with passenger inserted) + * @param originalPoints Original route points (before passenger insertion) + * @param modifiedPoints Modified route points (after passenger insertion) + * @return Baseline segment index if endpoints match, or -1 if segment must be routed + */ + private int getBaselineSegmentIndex( + int modifiedIndex, + List originalPoints, + List modifiedPoints + ) { + // Get the start and end coordinates of this modified segment + WgsCoordinate modifiedStart = modifiedPoints.get(modifiedIndex); + WgsCoordinate modifiedEnd = modifiedPoints.get(modifiedIndex + 1); + + // Search through baseline segments to find one with matching endpoints + for (int baselineIndex = 0; baselineIndex < originalPoints.size() - 1; baselineIndex++) { + WgsCoordinate baselineStart = originalPoints.get(baselineIndex); + WgsCoordinate baselineEnd = originalPoints.get(baselineIndex + 1); + + // Check if both endpoints match (using WgsCoordinate's built-in equality) + if (modifiedStart.equals(baselineStart) && modifiedEnd.equals(baselineEnd)) { + LOG.trace( + "Modified segment {} matches baseline segment {} (endpoints match)", + modifiedIndex, + baselineIndex + ); + return baselineIndex; + } + } + + // No matching baseline segment found - this segment must be routed + LOG.trace( + "Modified segment {} has no matching baseline segment (endpoints changed)", + modifiedIndex + ); + return -1; + } + + /** + * Functional interface for street routing. + */ + @FunctionalInterface + public interface RoutingFunction { + GraphPath route(GenericLocation from, GenericLocation to); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java new file mode 100644 index 00000000000..67932e49bc2 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java @@ -0,0 +1,51 @@ +package org.opentripplanner.ext.carpooling.routing; + +/** + * Represents a pickup and dropoff position pair that passed heuristic validation. + *

+ * This is an intermediate value used between finding viable positions (via heuristics) + * and evaluating them (via A* routing). Positions are 1-indexed to match the insertion + * point semantics in the route modification algorithm. + * + * @param pickupPos Position to insert passenger pickup (1-indexed) + * @param dropoffPos Position to insert passenger dropoff (1-indexed, always > pickupPos) + */ +public record InsertionPosition(int pickupPos, int dropoffPos) { + public InsertionPosition { + if (dropoffPos <= pickupPos) { + throw new IllegalArgumentException( + "dropoffPos (%d) must be greater than pickupPos (%d)".formatted(dropoffPos, pickupPos) + ); + } + } + + /** + * Maps an index in the original route to the corresponding index in the + * modified route after passenger stops have been inserted. + *

+ * When a passenger pickup and dropoff are inserted into a route, all subsequent + * indices shift. This method calculates the new index for an original route point. + * + * @param originalIndex Index in original route (before passenger insertion) + * @param pickupPos Position where pickup was inserted (1-indexed) + * @param dropoffPos Position where dropoff was inserted (1-indexed) + * @return Corresponding index in modified route (after passenger insertion) + */ + public static int mapOriginalIndex(int originalIndex, int pickupPos, int dropoffPos) { + int modifiedIndex = originalIndex; + + // Account for pickup insertion + // If the original point was at or after pickupPos, it shifts by 1 + if (originalIndex >= pickupPos) { + modifiedIndex++; + } + + // Account for dropoff insertion + // After pickup insertion, check if the shifted index is at or after dropoffPos + if (modifiedIndex >= dropoffPos) { + modifiedIndex++; + } + + return modifiedIndex; + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java new file mode 100644 index 00000000000..e563573d60d --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java @@ -0,0 +1,279 @@ +package org.opentripplanner.ext.carpooling.routing; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; +import org.opentripplanner.ext.carpooling.util.BeelineEstimator; +import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Finds viable insertion positions for a passenger in a carpool trip using fast heuristics. + *

+ * This class performs early-stage filtering to identify pickup/dropoff position pairs that + * are worth evaluating with expensive A* routing. It validates positions using: + *

    + *
  • Capacity constraints - ensures available seats throughout the journey
  • + *
  • Directional compatibility - prevents backtracking and U-turns
  • + *
  • Beeline delay heuristic - optimistic straight-line time estimates
  • + *
+ *

+ * By rejecting incompatible positions early, this class significantly reduces the number + * of expensive routing operations needed by {@link OptimalInsertionStrategy}. + *

+ * This follows the established OTP pattern of separating candidate generation from evaluation, + * similar to {@code TransferGenerator} and {@code StreetNearbyStopFinder}. + */ +public class InsertionPositionFinder { + + private static final Logger LOG = LoggerFactory.getLogger(InsertionPositionFinder.class); + + /** Maximum bearing deviation allowed for forward progress (90° allows detours, prevents U-turns) */ + private static final double FORWARD_PROGRESS_TOLERANCE_DEGREES = 90.0; + + private final PassengerDelayConstraints delayConstraints; + private final BeelineEstimator beelineEstimator; + + /** + * Creates a finder with default constraints and estimator. + */ + public InsertionPositionFinder() { + this(new PassengerDelayConstraints(), new BeelineEstimator()); + } + + /** + * Creates a finder with specified constraints and estimator. + * + * @param delayConstraints Constraints for acceptable passenger delays + * @param beelineEstimator Estimator for beeline travel times + */ + public InsertionPositionFinder( + PassengerDelayConstraints delayConstraints, + BeelineEstimator beelineEstimator + ) { + this.delayConstraints = delayConstraints; + this.beelineEstimator = beelineEstimator; + } + + /** + * Finds insertion positions that pass validation and beeline checks. + * This is done BEFORE any expensive routing to eliminate positions early. + * + * @param trip The carpool trip being evaluated + * @param passengerPickup Passenger's pickup location + * @param passengerDropoff Passenger's dropoff location + * @return List of viable insertion positions (may be empty) + */ + public List findViablePositions( + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + // Extract route points from trip + List routePoints = trip.routePoints(); + + // Calculate beeline times internally - this is an implementation detail + Duration[] beelineTimes = beelineEstimator.calculateCumulativeTimes(routePoints); + + List viable = new ArrayList<>(); + + for (int pickupPos = 1; pickupPos <= routePoints.size(); pickupPos++) { + for (int dropoffPos = pickupPos + 1; dropoffPos <= routePoints.size() + 1; dropoffPos++) { + if (!trip.hasCapacityForInsertion(pickupPos, dropoffPos, 1)) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected by capacity check", + pickupPos, + dropoffPos + ); + continue; + } + + // Directional validation + if ( + !insertionMaintainsForwardProgress( + routePoints, + pickupPos, + dropoffPos, + passengerPickup, + passengerDropoff + ) + ) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected by directional check", + pickupPos, + dropoffPos + ); + continue; + } + + // Beeline delay check (only if there are existing stops to protect) + if (routePoints.size() > 2) { + if ( + !passesBeelineDelayCheck( + routePoints, + beelineTimes, + passengerPickup, + passengerDropoff, + pickupPos, + dropoffPos + ) + ) { + LOG.trace( + "Insertion at pickup={}, dropoff={} rejected by beeline delay heuristic", + pickupPos, + dropoffPos + ); + continue; + } + } + + // This position passed all checks! + viable.add(new InsertionPosition(pickupPos, dropoffPos)); + } + } + + return viable; + } + + /** + * Checks if inserting pickup/dropoff points maintains forward progress. + * Prevents backtracking by ensuring insertions don't cause the route + * to deviate too far from its intended direction. + * + * @param routePoints Current route points + * @param pickupPos Position to insert pickup (1-indexed) + * @param dropoffPos Position to insert dropoff (1-indexed) + * @param passengerPickup Passenger pickup coordinate + * @param passengerDropoff Passenger dropoff coordinate + * @return true if insertion maintains forward progress + */ + private boolean insertionMaintainsForwardProgress( + List routePoints, + int pickupPos, + int dropoffPos, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff + ) { + // Validate pickup insertion + if (pickupPos > 0 && pickupPos < routePoints.size()) { + WgsCoordinate prevPoint = routePoints.get(pickupPos - 1); + WgsCoordinate nextPoint = routePoints.get(pickupPos); + + if (!maintainsForwardProgress(prevPoint, passengerPickup, nextPoint)) { + return false; + } + } + + // Validate dropoff insertion (in modified route with pickup already inserted) + int dropoffPosInModified = dropoffPos; + if (dropoffPosInModified > 0 && dropoffPosInModified <= routePoints.size()) { + // Get the previous point (which might be the pickup if dropoff is right after) + WgsCoordinate prevPoint; + if (dropoffPosInModified == pickupPos) { + prevPoint = passengerPickup; // Previous point is the pickup + } else if (dropoffPosInModified - 1 < routePoints.size()) { + prevPoint = routePoints.get(dropoffPosInModified - 1); + } else { + // Edge case: dropoff at the end + return true; + } + + // Get next point if it exists + if (dropoffPosInModified < routePoints.size()) { + WgsCoordinate nextPoint = routePoints.get(dropoffPosInModified); + + if (!maintainsForwardProgress(prevPoint, passengerDropoff, nextPoint)) { + return false; + } + } + } + + return true; + } + + /** + * Checks if inserting a new point maintains forward progress. + */ + private boolean maintainsForwardProgress( + WgsCoordinate previous, + WgsCoordinate newPoint, + WgsCoordinate next + ) { + // Calculate intended direction (previous → next) + double intendedBearing = DirectionalCalculator.calculateBearing(previous, next); + + // Calculate detour directions + double bearingToNew = DirectionalCalculator.calculateBearing(previous, newPoint); + double bearingFromNew = DirectionalCalculator.calculateBearing(newPoint, next); + + // Check deviations + double deviationToNew = DirectionalCalculator.bearingDifference(intendedBearing, bearingToNew); + double deviationFromNew = DirectionalCalculator.bearingDifference( + intendedBearing, + bearingFromNew + ); + + // Allow some deviation but not complete reversal + return ( + deviationToNew <= FORWARD_PROGRESS_TOLERANCE_DEGREES && + deviationFromNew <= FORWARD_PROGRESS_TOLERANCE_DEGREES + ); + } + + /** + * Checks if an insertion position passes the beeline delay heuristic. + * This is a fast, optimistic check using straight-line distance estimates. + * If this check fails, we know the actual A* routing will also fail, so we + * can skip the expensive routing calculation. + * + * @param originalCoords Original route coordinates + * @param originalBeelineTimes Beeline cumulative times for original route + * @param passengerPickup Passenger pickup location + * @param passengerDropoff Passenger dropoff location + * @param pickupPos Pickup insertion position (1-indexed) + * @param dropoffPos Dropoff insertion position (1-indexed) + * @return true if insertion might satisfy delay constraints (proceed with A* routing) + */ + private boolean passesBeelineDelayCheck( + List originalCoords, + Duration[] originalBeelineTimes, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + int pickupPos, + int dropoffPos + ) { + // Build modified coordinate list with passenger stops inserted + List modifiedCoords = new ArrayList<>(originalCoords); + modifiedCoords.add(pickupPos, passengerPickup); + modifiedCoords.add(dropoffPos, passengerDropoff); + + // Calculate beeline times for modified route + Duration[] modifiedBeelineTimes = beelineEstimator.calculateCumulativeTimes(modifiedCoords); + + // Check delays at each existing stop (exclude boarding at 0 and alighting at end) + for (int originalIndex = 1; originalIndex < originalCoords.size() - 1; originalIndex++) { + int modifiedIndex = InsertionPosition.mapOriginalIndex(originalIndex, pickupPos, dropoffPos); + + Duration originalTime = originalBeelineTimes[originalIndex]; + Duration modifiedTime = modifiedBeelineTimes[modifiedIndex]; + Duration beelineDelay = modifiedTime.minus(originalTime); + + // If even the optimistic beeline estimate exceeds threshold, actual routing will too + if (beelineDelay.compareTo(delayConstraints.getMaxDelay()) > 0) { + LOG.trace( + "Stop at position {} has beeline delay {}s (exceeds {}s threshold)", + originalIndex, + beelineDelay.getSeconds(), + delayConstraints.getMaxDelay().getSeconds() + ); + return false; // Reject early! + } + } + + return true; // Passes beeline check, proceed with A* routing + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java deleted file mode 100644 index 0beaf2e43b2..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategy.java +++ /dev/null @@ -1,410 +0,0 @@ -package org.opentripplanner.ext.carpooling.routing; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; -import org.opentripplanner.ext.carpooling.model.CarpoolStop; -import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.ext.carpooling.util.BeelineEstimator; -import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator; -import org.opentripplanner.framework.geometry.WgsCoordinate; -import org.opentripplanner.model.GenericLocation; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.state.State; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Finds the optimal insertion positions for a passenger in a carpool trip. - *

- * Uses a brute-force approach to try all valid insertion combinations and - * selects the one with minimum additional travel time. Delegates validation - * to pluggable validators. - *

- * Algorithm: - * 1. Build route points from the trip - * 2. For each possible pickup position: - * - For each possible dropoff position after pickup: - * - Validate the insertion - * - Calculate route with actual A* routing - * - Track the best (minimum additional duration) - * 3. Return the optimal candidate - */ -public class OptimalInsertionStrategy { - - private static final Logger LOG = LoggerFactory.getLogger(OptimalInsertionStrategy.class); - - private final InsertionValidator validator; - private final RoutingFunction routingFunction; - private final PassengerDelayConstraints delayConstraints; - private final BeelineEstimator beelineEstimator; - - public OptimalInsertionStrategy(InsertionValidator validator, RoutingFunction routingFunction) { - this(validator, routingFunction, new PassengerDelayConstraints(), new BeelineEstimator()); - } - - public OptimalInsertionStrategy( - InsertionValidator validator, - RoutingFunction routingFunction, - PassengerDelayConstraints delayConstraints - ) { - this(validator, routingFunction, delayConstraints, new BeelineEstimator()); - } - - public OptimalInsertionStrategy( - InsertionValidator validator, - RoutingFunction routingFunction, - PassengerDelayConstraints delayConstraints, - BeelineEstimator beelineEstimator - ) { - this.validator = validator; - this.routingFunction = routingFunction; - this.delayConstraints = delayConstraints; - this.beelineEstimator = beelineEstimator; - } - - /** - * Finds the optimal insertion for a passenger in a trip. - * - * @param trip The carpool trip - * @param passengerPickup Passenger's pickup location - * @param passengerDropoff Passenger's dropoff location - * @return The optimal insertion candidate, or null if no valid insertion exists - */ - public InsertionCandidate findOptimalInsertion( - CarpoolTrip trip, - WgsCoordinate passengerPickup, - WgsCoordinate passengerDropoff - ) { - // Build route points and passenger timeline - List routePoints = buildRoutePoints(trip); - PassengerCountTimeline passengerTimeline = PassengerCountTimeline.build(trip); - - LOG.debug( - "Evaluating insertion for trip {} with {} route points, {} capacity", - trip.getId(), - routePoints.size(), - trip.availableSeats() - ); - - // Calculate baseline duration and cumulative times (current route without new passenger) - Duration[] originalCumulativeTimes = calculateCumulativeTimes(routePoints); - if (originalCumulativeTimes == null) { - LOG.warn("Could not calculate baseline route for trip {}", trip.getId()); - return null; - } - Duration baselineDuration = originalCumulativeTimes[originalCumulativeTimes.length - 1]; - - // Calculate beeline estimates for original route (for early rejection heuristic) - List originalCoords = routePoints.stream().map(RoutePoint::coordinate).toList(); - Duration[] originalBeelineTimes = beelineEstimator.calculateCumulativeTimes(originalCoords); - - InsertionCandidate bestCandidate = null; - Duration minAdditionalDuration = Duration.ofDays(1); - - // Try all valid insertion positions - for (int pickupPos = 1; pickupPos <= routePoints.size(); pickupPos++) { - for (int dropoffPos = pickupPos + 1; dropoffPos <= routePoints.size() + 1; dropoffPos++) { - // Create validation context - List routeCoords = routePoints.stream().map(RoutePoint::coordinate).toList(); - - var validationContext = new InsertionValidator.ValidationContext( - pickupPos, - dropoffPos, - passengerPickup, - passengerDropoff, - routeCoords, - passengerTimeline - ); - - // Validate insertion - var validationResult = validator.validate(validationContext); - if (!validationResult.isValid()) { - LOG.trace( - "Insertion at pickup={}, dropoff={} rejected: {}", - pickupPos, - dropoffPos, - validationResult.reason() - ); - continue; - } - - // Beeline delay heuristic check (early rejection before expensive A* routing) - // Only check if there are existing stops to protect - if (originalCoords.size() > 2) { - if ( - !passesBeelineDelayCheck( - originalCoords, - originalBeelineTimes, - passengerPickup, - passengerDropoff, - pickupPos, - dropoffPos - ) - ) { - LOG.trace( - "Insertion at pickup={}, dropoff={} rejected by beeline delay heuristic", - pickupPos, - dropoffPos - ); - continue; // Skip expensive A* routing! - } - } - - // Calculate route with insertion - InsertionCandidate candidate = evaluateInsertion( - trip, - routePoints, - pickupPos, - dropoffPos, - passengerPickup, - passengerDropoff, - baselineDuration, - originalCumulativeTimes - ); - - if (candidate != null) { - Duration additionalDuration = candidate.additionalDuration(); - - // Check if this is the best so far and within deviation budget - if ( - additionalDuration.compareTo(minAdditionalDuration) < 0 && - additionalDuration.compareTo(trip.deviationBudget()) <= 0 - ) { - minAdditionalDuration = additionalDuration; - bestCandidate = candidate; - LOG.debug( - "New best insertion: pickup@{}, dropoff@{}, additional={}s", - pickupPos, - dropoffPos, - additionalDuration.getSeconds() - ); - } - } - } - } - - if (bestCandidate == null) { - LOG.debug("No valid insertion found for trip {}", trip.getId()); - } else { - LOG.info( - "Optimal insertion for trip {}: pickup@{}, dropoff@{}, additional={}s", - trip.getId(), - bestCandidate.pickupPosition(), - bestCandidate.dropoffPosition(), - bestCandidate.additionalDuration().getSeconds() - ); - } - - return bestCandidate; - } - - /** - * Evaluates a specific insertion configuration by routing all segments. - */ - private InsertionCandidate evaluateInsertion( - CarpoolTrip trip, - List originalPoints, - int pickupPos, - int dropoffPos, - WgsCoordinate passengerPickup, - WgsCoordinate passengerDropoff, - Duration baselineDuration, - Duration[] originalCumulativeTimes - ) { - // Build modified route with passenger stops inserted - List modifiedPoints = new ArrayList<>(originalPoints); - modifiedPoints.add(pickupPos, new RoutePoint(passengerPickup, "Passenger-Pickup")); - modifiedPoints.add(dropoffPos, new RoutePoint(passengerDropoff, "Passenger-Dropoff")); - - // Route all segments - List> segments = new ArrayList<>(); - Duration totalDuration = Duration.ZERO; - - for (int i = 0; i < modifiedPoints.size() - 1; i++) { - GenericLocation from = toGenericLocation(modifiedPoints.get(i).coordinate()); - GenericLocation to = toGenericLocation(modifiedPoints.get(i + 1).coordinate()); - - GraphPath segment = routingFunction.route(from, to); - if (segment == null) { - LOG.trace("Routing failed for segment {} → {}", i, i + 1); - return null; // This insertion is not viable - } - - segments.add(segment); - totalDuration = totalDuration.plus( - Duration.between(segment.states.getFirst().getTime(), segment.states.getLast().getTime()) - ); - } - - // Check passenger delay constraints - if ( - !delayConstraints.satisfiesConstraints( - originalCumulativeTimes, - segments, - pickupPos, - dropoffPos - ) - ) { - LOG.trace( - "Insertion at pickup={}, dropoff={} rejected by delay constraints", - pickupPos, - dropoffPos - ); - return null; - } - - return new InsertionCandidate( - trip, - pickupPos, - dropoffPos, - segments, - baselineDuration, - totalDuration - ); - } - - /** - * Builds route points from a trip. - */ - private List buildRoutePoints(CarpoolTrip trip) { - List points = new ArrayList<>(); - - // Boarding area - points.add(new RoutePoint(trip.boardingArea().getCoordinate(), "Boarding-" + trip.getId())); - - // Existing stops - for (CarpoolStop stop : trip.stops()) { - points.add(new RoutePoint(stop.getCoordinate(), "Stop-" + stop.getSequenceNumber())); - } - - // Alighting area - points.add(new RoutePoint(trip.alightingArea().getCoordinate(), "Alighting-" + trip.getId())); - - return points; - } - - /** - * Calculates cumulative durations to each point in the route. - * Returns an array where index i contains the cumulative duration to reach point i. - * - * @param routePoints The route points - * @return Array of cumulative durations, or null if routing fails - */ - private Duration[] calculateCumulativeTimes(List routePoints) { - Duration[] cumulativeTimes = new Duration[routePoints.size()]; - cumulativeTimes[0] = Duration.ZERO; - - for (int i = 0; i < routePoints.size() - 1; i++) { - GenericLocation from = toGenericLocation(routePoints.get(i).coordinate()); - GenericLocation to = toGenericLocation(routePoints.get(i + 1).coordinate()); - - GraphPath segment = routingFunction.route(from, to); - if (segment == null) { - return null; - } - - Duration segmentDuration = Duration.between( - segment.states.getFirst().getTime(), - segment.states.getLast().getTime() - ); - cumulativeTimes[i + 1] = cumulativeTimes[i].plus(segmentDuration); - } - - return cumulativeTimes; - } - - private GenericLocation toGenericLocation(WgsCoordinate coord) { - return GenericLocation.fromCoordinate(coord.latitude(), coord.longitude()); - } - - /** - * Checks if an insertion position passes the beeline delay heuristic. - * This is a fast, optimistic check using straight-line distance estimates. - * If this check fails, we know the actual A* routing will also fail, so we - * can skip the expensive routing calculation. - * - * @param originalCoords Original route coordinates - * @param originalBeelineTimes Beeline cumulative times for original route - * @param passengerPickup Passenger pickup location - * @param passengerDropoff Passenger dropoff location - * @param pickupPos Pickup insertion position (1-indexed) - * @param dropoffPos Dropoff insertion position (1-indexed) - * @return true if insertion might satisfy delay constraints (proceed with A* routing) - */ - private boolean passesBeelineDelayCheck( - List originalCoords, - Duration[] originalBeelineTimes, - WgsCoordinate passengerPickup, - WgsCoordinate passengerDropoff, - int pickupPos, - int dropoffPos - ) { - // Build modified coordinate list with passenger stops inserted - List modifiedCoords = new ArrayList<>(originalCoords); - modifiedCoords.add(pickupPos, passengerPickup); - modifiedCoords.add(dropoffPos, passengerDropoff); - - // Calculate beeline times for modified route - Duration[] modifiedBeelineTimes = beelineEstimator.calculateCumulativeTimes(modifiedCoords); - - // Check delays at each existing stop (exclude boarding at 0 and alighting at end) - for (int originalIndex = 1; originalIndex < originalCoords.size() - 1; originalIndex++) { - int modifiedIndex = getModifiedIndex(originalIndex, pickupPos, dropoffPos); - - Duration originalTime = originalBeelineTimes[originalIndex]; - Duration modifiedTime = modifiedBeelineTimes[modifiedIndex]; - Duration beelineDelay = modifiedTime.minus(originalTime); - - // If even the optimistic beeline estimate exceeds threshold, actual routing will too - if (beelineDelay.compareTo(delayConstraints.getMaxDelay()) > 0) { - LOG.trace( - "Stop at position {} has beeline delay {}s (exceeds {}s threshold)", - originalIndex, - beelineDelay.getSeconds(), - delayConstraints.getMaxDelay().getSeconds() - ); - return false; // Reject early! - } - } - - return true; // Passes beeline check, proceed with A* routing - } - - /** - * Maps an index in the original route to the corresponding index in the - * modified route after passenger stops have been inserted. - * - * @param originalIndex Index in original route - * @param pickupPos Position where pickup was inserted (1-indexed) - * @param dropoffPos Position where dropoff was inserted (1-indexed) - * @return Corresponding index in modified route - */ - private int getModifiedIndex(int originalIndex, int pickupPos, int dropoffPos) { - int modifiedIndex = originalIndex; - - // Account for pickup insertion - if (originalIndex >= pickupPos) { - modifiedIndex++; - } - - // Account for dropoff insertion (after pickup has been inserted) - if (modifiedIndex >= dropoffPos) { - modifiedIndex++; - } - - return modifiedIndex; - } - - /** - * Functional interface for street routing. - */ - @FunctionalInterface - public interface RoutingFunction { - GraphPath route(GenericLocation from, GenericLocation to); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java deleted file mode 100644 index 78d4f88ec03..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutePoint.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.opentripplanner.ext.carpooling.routing; - -import org.opentripplanner.framework.geometry.WgsCoordinate; - -/** - * Represents a point along a carpool route. - *

- * Route points include the boarding area, intermediate stops, and alighting area. - * Each point has a coordinate and a descriptive label for debugging. - */ -public record RoutePoint(WgsCoordinate coordinate, String label) { - public RoutePoint { - if (coordinate == null) { - throw new IllegalArgumentException("Coordinate cannot be null"); - } - if (label == null || label.isBlank()) { - throw new IllegalArgumentException("Label cannot be null or blank"); - } - } - - @Override - public String toString() { - return String.format("%s (%.4f, %.4f)", label, coordinate.latitude(), coordinate.longitude()); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index 4fc2951d207..a1661265da4 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -1,5 +1,6 @@ package org.opentripplanner.ext.carpooling.service; +import java.time.Duration; import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -8,11 +9,14 @@ import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; import org.opentripplanner.ext.carpooling.filter.FilterChain; import org.opentripplanner.ext.carpooling.internal.CarpoolItineraryMapper; import org.opentripplanner.ext.carpooling.routing.InsertionCandidate; -import org.opentripplanner.ext.carpooling.routing.OptimalInsertionStrategy; -import org.opentripplanner.ext.carpooling.validation.CompositeValidator; +import org.opentripplanner.ext.carpooling.routing.InsertionEvaluator; +import org.opentripplanner.ext.carpooling.routing.InsertionPosition; +import org.opentripplanner.ext.carpooling.routing.InsertionPositionFinder; +import org.opentripplanner.ext.carpooling.util.BeelineEstimator; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.routing.api.request.RouteRequest; @@ -36,12 +40,43 @@ import org.slf4j.LoggerFactory; /** - * Refactored carpooling service using the new modular architecture. + * Default implementation of {@link CarpoolingService} that orchestrates the two-phase + * carpooling routing algorithm: position finding and insertion evaluation. *

- * Orchestrates: - * - Pre-filtering trips with FilterChain - * - Finding optimal insertions with OptimalInsertionStrategy - * - Mapping results to itineraries with CarpoolItineraryMapper + * This service is the main entry point for carpool routing functionality. It coordinates multiple + * components to efficiently find viable carpool matches while minimizing expensive routing + * calculations through strategic filtering and early rejection. + * + *

Algorithm Phases

+ *

+ * The service executes routing requests in three distinct phases: + *

    + *
  1. Pre-filtering ({@link FilterChain}): Quickly eliminates incompatible + * trips based on capacity, time windows, direction, and distance.
  2. + *
  3. Position Finding ({@link InsertionPositionFinder}): For trips that + * pass filtering, identifies viable pickup/dropoff position pairs using fast heuristics + * (capacity, direction, beeline delay estimates). No routing is performed in this phase.
  4. + *
  5. Insertion Evaluation ({@link InsertionEvaluator}): For viable positions, + * computes actual routes using A* street routing. Evaluates all feasible insertion positions + * and selects the one minimizing additional travel time while satisfying delay constraints.
  6. + *
+ * + *

Component Dependencies

+ *
    + *
  • {@link CarpoolingRepository}: Source of available driver trips
  • + *
  • {@link Graph}: Street network for routing calculations
  • + *
  • {@link VertexLinker}: Links coordinates to graph vertices
  • + *
  • {@link StreetLimitationParametersService}: Street routing configuration
  • + *
  • {@link FilterChain}: Pre-screening filters
  • + *
  • {@link InsertionPositionFinder}: Heuristic position filtering
  • + *
  • {@link InsertionEvaluator}: Routing evaluation and selection
  • + *
  • {@link CarpoolItineraryMapper}: Maps insertions to OTP itineraries
  • + *
+ * + * @see CarpoolingService for interface documentation and usage examples + * @see FilterChain for filtering strategy details + * @see InsertionPositionFinder for position finding strategy details + * @see InsertionEvaluator for insertion evaluation algorithm details */ public class DefaultCarpoolingService implements CarpoolingService { @@ -53,9 +88,21 @@ public class DefaultCarpoolingService implements CarpoolingService { private final VertexLinker vertexLinker; private final StreetLimitationParametersService streetLimitationParametersService; private final FilterChain preFilters; - private final CompositeValidator insertionValidator; private final CarpoolItineraryMapper itineraryMapper; + /** + * Creates a new carpooling service with the specified dependencies. + *

+ * The service is initialized with a standard filter chain. The filter chain + * is currently hardcoded but could be made configurable in future versions. + * + * @param repository provides access to active driver trips, must not be null + * @param graph the street network used for routing calculations, must not be null + * @param vertexLinker links coordinates to graph vertices for routing, must not be null + * @param streetLimitationParametersService provides street routing configuration including + * speed limits, must not be null + * @throws NullPointerException if any parameter is null + */ public DefaultCarpoolingService( CarpoolingRepository repository, Graph graph, @@ -67,16 +114,13 @@ public DefaultCarpoolingService( this.vertexLinker = vertexLinker; this.streetLimitationParametersService = streetLimitationParametersService; this.preFilters = FilterChain.standard(); - this.insertionValidator = CompositeValidator.standard(); this.itineraryMapper = new CarpoolItineraryMapper(); } @Override public List route(RouteRequest request) throws RoutingValidationException { - // Validate request validateRequest(request); - // Extract passenger coordinates and time WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate()); WgsCoordinate passengerDropoff = new WgsCoordinate(request.to().getCoordinate()); var passengerDepartureTime = request.dateTime(); @@ -88,15 +132,19 @@ public List route(RouteRequest request) throws RoutingValidationExcep passengerDepartureTime ); - // Get all trips from repository var allTrips = repository.getCarpoolTrips(); LOG.debug("Repository contains {} carpool trips", allTrips.size()); - // Apply pre-filters (fast rejection) - pass time for time-aware filters var candidateTrips = allTrips .stream() .filter(trip -> - preFilters.accepts(trip, passengerPickup, passengerDropoff, passengerDepartureTime) + preFilters.accepts( + trip, + passengerPickup, + passengerDropoff, + passengerDepartureTime, + request.searchWindow() == null ? Duration.ofMinutes(30) : request.searchWindow() + ) ) .toList(); @@ -110,18 +158,45 @@ public List route(RouteRequest request) throws RoutingValidationExcep return List.of(); } - // Create routing function var routingFunction = createRoutingFunction(request); - // Create insertion strategy - var insertionStrategy = new OptimalInsertionStrategy(insertionValidator, routingFunction); + // Phase 1: Find viable positions using fast heuristics (no routing) + var delayConstraints = new PassengerDelayConstraints(); + var positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator()); + + // Phase 2: Evaluate positions with expensive A* routing + var insertionEvaluator = new InsertionEvaluator(routingFunction, delayConstraints); // Find optimal insertions for remaining trips var insertionCandidates = candidateTrips .stream() - .map(trip -> insertionStrategy.findOptimalInsertion(trip, passengerPickup, passengerDropoff)) + .map(trip -> { + List viablePositions = positionFinder.findViablePositions( + trip, + passengerPickup, + passengerDropoff + ); + + if (viablePositions.isEmpty()) { + LOG.debug("No viable positions found for trip {} (avoided all routing!)", trip.getId()); + return null; + } + + LOG.debug( + "{} viable positions found for trip {}, evaluating with routing", + viablePositions.size(), + trip.getId() + ); + + // Evaluate only viable positions with expensive routing + return insertionEvaluator.findBestInsertion( + trip, + viablePositions, + passengerPickup, + passengerDropoff + ); + }) .filter(Objects::nonNull) - .filter(InsertionCandidate::isWithinDeviationBudget) .sorted(Comparator.comparing(InsertionCandidate::additionalDuration)) .limit(DEFAULT_MAX_CARPOOL_RESULTS) .toList(); @@ -139,9 +214,6 @@ public List route(RouteRequest request) throws RoutingValidationExcep return itineraries; } - /** - * Validates the route request. - */ private void validateRequest(RouteRequest request) throws RoutingValidationException { if ( Objects.requireNonNull(request.from()).lat == null || @@ -162,9 +234,26 @@ private void validateRequest(RouteRequest request) throws RoutingValidationExcep } /** - * Creates a routing function that performs A* street routing. + * Creates a routing function that performs A* street routing between coordinate pairs. + *

+ * The returned function encapsulates all dependencies needed for routing (graph, vertex linker, + * street parameters) so that {@link InsertionEvaluator} can perform routing without + * knowing about OTP's internal routing infrastructure. This abstraction allows the evaluator + * to remain focused on optimization logic rather than routing mechanics. + * + *

Routing Strategy

+ *
    + *
  • Mode: CAR mode for both origin and destination
  • + *
  • Algorithm: A* with Euclidean heuristic
  • + *
  • Vertex Linking: Creates temporary vertices at coordinate locations
  • + *
  • Error Handling: Returns null on routing failure (logged as warning)
  • + *
+ * + * @param request the route request containing preferences and parameters for routing + * @return a routing function that performs A* routing between two coordinates, returning + * null if routing fails for any reason (network unreachable, timeout, etc.) */ - private OptimalInsertionStrategy.RoutingFunction createRoutingFunction(RouteRequest request) { + private InsertionEvaluator.RoutingFunction createRoutingFunction(RouteRequest request) { return (from, to) -> { try { var tempVertices = new TemporaryVerticesContainer( @@ -177,10 +266,12 @@ private OptimalInsertionStrategy.RoutingFunction createRoutingFunction(RouteRequ StreetMode.CAR ); - return performCarRouting( + return carpoolRouting( request, + new StreetRequest(StreetMode.CAR), tempVertices.getFromVertices(), - tempVertices.getToVertices() + tempVertices.getToVertices(), + streetLimitationParametersService.getMaxCarSpeed() ); } catch (Exception e) { LOG.warn("Routing failed from {} to {}: {}", from, to, e.getMessage()); @@ -190,24 +281,22 @@ private OptimalInsertionStrategy.RoutingFunction createRoutingFunction(RouteRequ } /** - * Performs A* car routing between two vertex sets. - */ - private GraphPath performCarRouting( - RouteRequest request, - java.util.Set from, - java.util.Set to - ) { - return carpoolRouting( - request, - new StreetRequest(StreetMode.CAR), - from, - to, - streetLimitationParametersService.getMaxCarSpeed() - ); - } - - /** - * Core A* routing for carpooling (optimized for car travel). + * Core A* routing for carpooling optimized for car travel. + *

+ * Configures and executes an A* street search with settings optimized for carpooling: + *

    + *
  • Heuristic: Euclidean distance with max car speed for admissibility
  • + *
  • Skip Strategy: Skips edges exceeding max direct duration limit
  • + *
  • Dominance: Minimum weight dominance (finds shortest path)
  • + *
  • Sorting: Results sorted by arrival time or departure time
  • + *
+ * + * @param routeRequest the route request containing preferences and parameters + * @param streetRequest the street request specifying CAR mode + * @param fromVertices set of origin vertices to start routing from + * @param toVertices set of destination vertices to route to + * @param maxCarSpeed maximum car speed in meters/second, used for heuristic calculation + * @return the first (best) path found, or null if no paths exist */ private GraphPath carpoolRouting( RouteRequest routeRequest, diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java index 14ab9867fc8..7ca742650e5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java @@ -56,130 +56,4 @@ public static double bearingDifference(double bearing1, double bearing2) { return diff; } - - /** - * Categorizes the directional relationship between two bearings. - */ - public enum DirectionalAlignment { - /** Directions are very similar (within 30°) - ideal match */ - HIGHLY_ALIGNED, - - /** Directions are compatible (within 60°) - acceptable match */ - ALIGNED, - - /** Directions differ but not opposite (60-120°) - marginal */ - DIVERGENT, - - /** Directions are opposite or very different (>120°) - incompatible */ - OPPOSITE; - - public static DirectionalAlignment categorize(double bearingDifference) { - if (bearingDifference <= 30.0) { - return HIGHLY_ALIGNED; - } else if (bearingDifference <= 60.0) { - return ALIGNED; - } else if (bearingDifference <= 120.0) { - return DIVERGENT; - } else { - return OPPOSITE; - } - } - } - - /** - * Classifies the directional alignment between two journeys. - * - * @param tripStart Starting point of the trip - * @param tripEnd Ending point of the trip - * @param passengerStart Passenger's starting point - * @param passengerEnd Passenger's ending point - * @return The alignment category - */ - public static DirectionalAlignment classify( - WgsCoordinate tripStart, - WgsCoordinate tripEnd, - WgsCoordinate passengerStart, - WgsCoordinate passengerEnd - ) { - double tripBearing = calculateBearing(tripStart, tripEnd); - double passengerBearing = calculateBearing(passengerStart, passengerEnd); - double difference = bearingDifference(tripBearing, passengerBearing); - - return DirectionalAlignment.categorize(difference); - } - - /** - * Checks if a passenger journey is directionally compatible with a carpool trip. - * - * @param tripStart Starting point of the carpool trip - * @param tripEnd Ending point of the carpool trip - * @param passengerStart Passenger's desired pickup location - * @param passengerEnd Passenger's desired dropoff location - * @param toleranceDegrees Maximum allowed bearing difference in degrees - * @return true if directions are compatible, false otherwise - */ - public static boolean isDirectionallyCompatible( - WgsCoordinate tripStart, - WgsCoordinate tripEnd, - WgsCoordinate passengerStart, - WgsCoordinate passengerEnd, - double toleranceDegrees - ) { - double tripBearing = calculateBearing(tripStart, tripEnd); - double passengerBearing = calculateBearing(passengerStart, passengerEnd); - double difference = bearingDifference(tripBearing, passengerBearing); - - return difference <= toleranceDegrees; - } - - /** - * Checks if adding a new point maintains forward progress along a route. - * - * @param previous Previous point in the route - * @param newPoint Point to be inserted - * @param next Next point in the route - * @param toleranceDegrees Maximum allowed deviation in degrees - * @return true if insertion maintains forward progress, false if it causes backtracking - */ - public static boolean maintainsForwardProgress( - WgsCoordinate previous, - WgsCoordinate newPoint, - WgsCoordinate next, - double toleranceDegrees - ) { - double intendedBearing = calculateBearing(previous, next); - double bearingToNew = calculateBearing(previous, newPoint); - double bearingFromNew = calculateBearing(newPoint, next); - - double deviationToNew = bearingDifference(intendedBearing, bearingToNew); - double deviationFromNew = bearingDifference(intendedBearing, bearingFromNew); - - return deviationToNew <= toleranceDegrees && deviationFromNew <= toleranceDegrees; - } - - /** - * Classifies directional alignment using custom thresholds. - * - * @param bearingDifference The bearing difference in degrees - * @param highlyAlignedThreshold Threshold for HIGHLY_ALIGNED (e.g., 30.0) - * @param alignedThreshold Threshold for ALIGNED (e.g., 60.0) - * @param divergentThreshold Threshold for DIVERGENT (e.g., 120.0) - * @return The alignment category - */ - public static DirectionalAlignment classify( - double bearingDifference, - double highlyAlignedThreshold, - double alignedThreshold, - double divergentThreshold - ) { - if (bearingDifference <= highlyAlignedThreshold) { - return DirectionalAlignment.HIGHLY_ALIGNED; - } else if (bearingDifference <= alignedThreshold) { - return DirectionalAlignment.ALIGNED; - } else if (bearingDifference <= divergentThreshold) { - return DirectionalAlignment.DIVERGENT; - } else { - return DirectionalAlignment.OPPOSITE; - } - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java deleted file mode 100644 index 40e4ca201be..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimeline.java +++ /dev/null @@ -1,141 +0,0 @@ -package org.opentripplanner.ext.carpooling.util; - -import java.util.ArrayList; -import java.util.List; -import org.opentripplanner.ext.carpooling.model.CarpoolStop; -import org.opentripplanner.ext.carpooling.model.CarpoolTrip; - -/** - * Tracks passenger counts at each position in a carpool route. - *

- * Index i represents the number of passengers AFTER position i (before the next segment). - *

- * Example: - *

- * Position:     0(Boarding)  1(Stop1)  2(Stop2)  3(Alighting)
- * Delta:              -         +2        -1           -
- * Passengers:    0        →  2      →  1       →  0
- * Timeline:     [0,           2,        1,           0]
- * 
- */ -public class PassengerCountTimeline { - - private final List counts; - private final int capacity; - - private PassengerCountTimeline(List counts, int capacity) { - this.counts = counts; - this.capacity = capacity; - } - - /** - * Builds a passenger count timeline from a carpool trip. - * - * @param trip The carpool trip - * @return Timeline tracking passenger counts at each position - */ - public static PassengerCountTimeline build(CarpoolTrip trip) { - List timeline = new ArrayList<>(); - int currentPassengers = 0; - - // Position 0: Boarding (no passengers yet) - timeline.add(currentPassengers); - - // Add passenger delta for each stop - for (CarpoolStop stop : trip.stops()) { - currentPassengers += stop.getPassengerDelta(); - timeline.add(currentPassengers); - } - - // Position N+1: Alighting (all passengers leave) - currentPassengers = 0; - timeline.add(currentPassengers); - - return new PassengerCountTimeline(timeline, trip.availableSeats()); - } - - /** - * Gets the passenger count at a specific position. - * - * @param position Position index - * @return Number of passengers after this position - */ - public int getPassengerCount(int position) { - if (position < 0 || position >= counts.size()) { - throw new IndexOutOfBoundsException( - "Position " + position + " out of bounds (size: " + counts.size() + ")" - ); - } - return counts.get(position); - } - - /** - * Gets the vehicle capacity. - */ - public int getCapacity() { - return capacity; - } - - /** - * Gets the number of positions tracked. - */ - public int size() { - return counts.size(); - } - - /** - * Checks if there's available capacity at a specific position. - * - * @param position Position to check - * @return true if there's at least one available seat - */ - public boolean hasCapacity(int position) { - return getPassengerCount(position) < capacity; - } - - /** - * Checks if there's capacity for a specific number of additional passengers. - * - * @param position Position to check - * @param additionalPassengers Number of passengers to add - * @return true if there's capacity for the additional passengers - */ - public boolean hasCapacityFor(int position, int additionalPassengers) { - return getPassengerCount(position) + additionalPassengers <= capacity; - } - - /** - * Checks if there's capacity throughout a range of positions. - *

- * This is useful for validating that adding a passenger between pickup and dropoff - * won't exceed capacity at any point along the route. - * - * @param startPosition First position (inclusive) - * @param endPosition Last position (exclusive) - * @param additionalPassengers Number of passengers to add - * @return true if capacity is available throughout the range - */ - public boolean hasCapacityInRange(int startPosition, int endPosition, int additionalPassengers) { - for (int pos = startPosition; pos < endPosition && pos < counts.size(); pos++) { - if (!hasCapacityFor(pos, additionalPassengers)) { - return false; - } - } - return true; - } - - /** - * Gets the available seat count at a position. - * - * @param position Position to check - * @return Number of available seats (capacity - current passengers) - */ - public int getAvailableSeats(int position) { - return capacity - getPassengerCount(position); - } - - @Override - public String toString() { - return "PassengerCountTimeline{counts=" + counts + ", capacity=" + capacity + "}"; - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java deleted file mode 100644 index 3c73505dcb5..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/RouteGeometry.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.opentripplanner.ext.carpooling.util; - -import java.util.List; -import org.opentripplanner.framework.geometry.WgsCoordinate; - -/** - * Utility methods for working with route geometry and geographic relationships. - */ -public class RouteGeometry { - - /** Tolerance for corridor checks (approximately 10km in degrees at mid-latitudes) */ - public static final double DEFAULT_CORRIDOR_TOLERANCE_DEGREES = 0.1; - - /** - * Represents a geographic bounding box. - */ - public record BoundingBox(double minLat, double maxLat, double minLon, double maxLon) { - /** - * Checks if a coordinate is within this bounding box. - */ - public boolean contains(WgsCoordinate coord) { - return ( - coord.latitude() >= minLat && - coord.latitude() <= maxLat && - coord.longitude() >= minLon && - coord.longitude() <= maxLon - ); - } - - /** - * Expands this bounding box by the given tolerance. - */ - public BoundingBox expand(double tolerance) { - return new BoundingBox( - minLat - tolerance, - maxLat + tolerance, - minLon - tolerance, - maxLon + tolerance - ); - } - } - - /** - * Calculates a bounding box for a list of coordinates. - * - * @param coordinates List of coordinates - * @return Bounding box containing all coordinates - */ - public static BoundingBox calculateBoundingBox(List coordinates) { - if (coordinates.isEmpty()) { - throw new IllegalArgumentException("Cannot calculate bounding box for empty list"); - } - - double minLat = Double.MAX_VALUE; - double maxLat = -Double.MAX_VALUE; - double minLon = Double.MAX_VALUE; - double maxLon = -Double.MAX_VALUE; - - for (WgsCoordinate coord : coordinates) { - minLat = Math.min(minLat, coord.latitude()); - maxLat = Math.max(maxLat, coord.latitude()); - minLon = Math.min(minLon, coord.longitude()); - maxLon = Math.max(maxLon, coord.longitude()); - } - - return new BoundingBox(minLat, maxLat, minLon, maxLon); - } - - /** - * Checks if a coordinate is within a corridor defined by a route segment. - *

- * This prevents matching passengers who are directionally aligned but geographically - * far from the actual route (e.g., parallel roads on opposite sides of a city). - * - * @param routeSegment Coordinates defining the route corridor - * @param coordinate Coordinate to check - * @param toleranceDegrees Corridor width tolerance in degrees - * @return true if the coordinate is within the corridor - */ - public static boolean isWithinCorridor( - List routeSegment, - WgsCoordinate coordinate, - double toleranceDegrees - ) { - BoundingBox box = calculateBoundingBox(routeSegment); - BoundingBox expandedBox = box.expand(toleranceDegrees); - return expandedBox.contains(coordinate); - } - - /** - * Checks if a coordinate is within a corridor using default tolerance. - */ - public static boolean isWithinCorridor( - List routeSegment, - WgsCoordinate coordinate - ) { - return isWithinCorridor(routeSegment, coordinate, DEFAULT_CORRIDOR_TOLERANCE_DEGREES); - } - - /** - * Checks if both pickup and dropoff are within the route corridor. - */ - public static boolean areBothWithinCorridor( - List routeSegment, - WgsCoordinate pickup, - WgsCoordinate dropoff, - double toleranceDegrees - ) { - BoundingBox box = calculateBoundingBox(routeSegment); - BoundingBox expandedBox = box.expand(toleranceDegrees); - return expandedBox.contains(pickup) && expandedBox.contains(dropoff); - } - - /** - * Checks if both coordinates are within the corridor using default tolerance. - */ - public static boolean areBothWithinCorridor( - List routeSegment, - WgsCoordinate pickup, - WgsCoordinate dropoff - ) { - return areBothWithinCorridor(routeSegment, pickup, dropoff, DEFAULT_CORRIDOR_TOLERANCE_DEGREES); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java deleted file mode 100644 index 59a67a3430b..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CapacityValidator.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Validates that inserting a passenger won't exceed vehicle capacity. - *

- * Checks all positions between pickup and dropoff to ensure capacity - * constraints are maintained throughout the passenger's journey. - */ -public class CapacityValidator implements InsertionValidator { - - private static final Logger LOG = LoggerFactory.getLogger(CapacityValidator.class); - - @Override - public ValidationResult validate(ValidationContext context) { - // Check capacity at pickup position - int pickupPassengers = context - .passengerTimeline() - .getPassengerCount(context.pickupPosition() - 1); - int capacity = context.passengerTimeline().getCapacity(); - - if (pickupPassengers >= capacity) { - String reason = String.format( - "No capacity at pickup position %d: %d passengers, %d capacity", - context.pickupPosition(), - pickupPassengers, - capacity - ); - LOG.debug(reason); - return ValidationResult.invalid(reason); - } - - // Check capacity throughout the journey (pickup to dropoff) - boolean hasCapacity = context - .passengerTimeline() - .hasCapacityInRange(context.pickupPosition(), context.dropoffPosition(), 1); - - if (!hasCapacity) { - String reason = String.format( - "Capacity exceeded between positions %d and %d", - context.pickupPosition(), - context.dropoffPosition() - ); - LOG.debug(reason); - return ValidationResult.invalid(reason); - } - - return ValidationResult.valid(); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java deleted file mode 100644 index c42f6a34919..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/CompositeValidator.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * Combines multiple insertion validators using AND logic. - *

- * All validators must pass for the insertion to be considered valid. - * Evaluation stops at the first failure (short-circuit). - */ -public class CompositeValidator implements InsertionValidator { - - private final List validators; - - public CompositeValidator(List validators) { - this.validators = new ArrayList<>(validators); - } - - public CompositeValidator(InsertionValidator... validators) { - this(Arrays.asList(validators)); - } - - /** - * Creates a standard validator with capacity and directional checks. - */ - public static CompositeValidator standard() { - return new CompositeValidator(new CapacityValidator(), new DirectionalValidator()); - } - - @Override - public ValidationResult validate(ValidationContext context) { - for (InsertionValidator validator : validators) { - ValidationResult result = validator.validate(context); - if (!result.isValid()) { - return result; // Short-circuit: return first failure - } - } - return ValidationResult.valid(); // All validators passed - } - - /** - * Adds a validator to the composite. - */ - public CompositeValidator add(InsertionValidator validator) { - validators.add(validator); - return this; - } - - /** - * Gets the number of validators. - */ - public int size() { - return validators.size(); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java deleted file mode 100644 index 0bb0b3e7944..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidator.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; -import org.opentripplanner.framework.geometry.WgsCoordinate; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Validates that inserting pickup/dropoff points maintains forward progress. - *

- * Prevents backtracking by checking that insertions don't cause the route - * to deviate too far from its intended direction. - */ -public class DirectionalValidator implements InsertionValidator { - - private static final Logger LOG = LoggerFactory.getLogger(DirectionalValidator.class); - - /** Maximum bearing deviation allowed for forward progress (90° allows detours, prevents U-turns) */ - public static final double FORWARD_PROGRESS_TOLERANCE_DEGREES = 90.0; - - private final double toleranceDegrees; - - public DirectionalValidator() { - this(FORWARD_PROGRESS_TOLERANCE_DEGREES); - } - - public DirectionalValidator(double toleranceDegrees) { - this.toleranceDegrees = toleranceDegrees; - } - - @Override - public ValidationResult validate(ValidationContext context) { - // Validate pickup insertion - if (context.pickupPosition() > 0 && context.pickupPosition() < context.routePoints().size()) { - WgsCoordinate prevPoint = context.routePoints().get(context.pickupPosition() - 1); - WgsCoordinate nextPoint = context.routePoints().get(context.pickupPosition()); - - if (!maintainsForwardProgress(prevPoint, context.pickup(), nextPoint)) { - String reason = String.format( - "Pickup insertion at position %d causes backtracking", - context.pickupPosition() - ); - LOG.debug(reason); - return ValidationResult.invalid(reason); - } - } - - // Validate dropoff insertion (in modified route with pickup already inserted) - // Note: dropoffPosition is in the context of the original route - // After pickup insertion, dropoff is one position later - int dropoffPosInModified = context.dropoffPosition(); - if (dropoffPosInModified > 0 && dropoffPosInModified <= context.routePoints().size()) { - // Get the previous point (which might be the pickup if dropoff is right after) - WgsCoordinate prevPoint; - if (dropoffPosInModified == context.pickupPosition()) { - prevPoint = context.pickup(); // Previous point is the pickup - } else if (dropoffPosInModified - 1 < context.routePoints().size()) { - prevPoint = context.routePoints().get(dropoffPosInModified - 1); - } else { - // Edge case: dropoff at the end - return ValidationResult.valid(); - } - - // Get next point if it exists - if (dropoffPosInModified < context.routePoints().size()) { - WgsCoordinate nextPoint = context.routePoints().get(dropoffPosInModified); - - if (!maintainsForwardProgress(prevPoint, context.dropoff(), nextPoint)) { - String reason = String.format( - "Dropoff insertion at position %d causes backtracking", - context.dropoffPosition() - ); - LOG.debug(reason); - return ValidationResult.invalid(reason); - } - } - } - - return ValidationResult.valid(); - } - - /** - * Checks if inserting a new point maintains forward progress. - */ - private boolean maintainsForwardProgress( - WgsCoordinate previous, - WgsCoordinate newPoint, - WgsCoordinate next - ) { - // Calculate intended direction (previous → next) - double intendedBearing = DirectionalCalculator.calculateBearing(previous, next); - - // Calculate detour directions - double bearingToNew = DirectionalCalculator.calculateBearing(previous, newPoint); - double bearingFromNew = DirectionalCalculator.calculateBearing(newPoint, next); - - // Check deviations - double deviationToNew = DirectionalCalculator.bearingDifference(intendedBearing, bearingToNew); - double deviationFromNew = DirectionalCalculator.bearingDifference( - intendedBearing, - bearingFromNew - ); - - // Allow some deviation but not complete reversal - return (deviationToNew <= toleranceDegrees && deviationFromNew <= toleranceDegrees); - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java deleted file mode 100644 index 3a3d1f72411..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/validation/InsertionValidator.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import org.opentripplanner.framework.geometry.WgsCoordinate; - -/** - * Validates whether an insertion of pickup/dropoff points into a route is valid. - *

- * Validators check specific constraints (capacity, direction, etc.) and can - * reject insertions that violate those constraints. - */ -@FunctionalInterface -public interface InsertionValidator { - /** - * Validates an insertion. - * - * @param context The validation context containing all necessary information - * @return Validation result indicating success or failure with reason - */ - ValidationResult validate(ValidationContext context); - - /** - * Context object containing all information needed for validation. - */ - record ValidationContext( - int pickupPosition, - int dropoffPosition, - WgsCoordinate pickup, - WgsCoordinate dropoff, - java.util.List routePoints, - org.opentripplanner.ext.carpooling.util.PassengerCountTimeline passengerTimeline - ) {} - - /** - * Result of a validation check. - */ - sealed interface ValidationResult { - boolean isValid(); - - String reason(); - - record Valid() implements ValidationResult { - @Override - public boolean isValid() { - return true; - } - - @Override - public String reason() { - return "Valid"; - } - } - - record Invalid(String reason) implements ValidationResult { - @Override - public boolean isValid() { - return false; - } - } - - static ValidationResult valid() { - return new Valid(); - } - - static ValidationResult invalid(String reason) { - return new Invalid(reason); - } - } - - /** - * Returns a validator that always accepts. - */ - static InsertionValidator acceptAll() { - return ctx -> ValidationResult.valid(); - } - - /** - * Combines this validator with another using AND logic. - */ - default InsertionValidator and(InsertionValidator other) { - return ctx -> { - ValidationResult first = this.validate(ctx); - if (!first.isValid()) { - return first; - } - return other.validate(ctx); - }; - } -} diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java index ebec980835e..eeb6175d6f8 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java @@ -177,7 +177,7 @@ public DataFetcher mode() { } if (leg instanceof CarpoolLeg cl) { // CarpoolLeg is a special case, it has no StreetLeg or TransitLeg, but we can still return the mode - return String.valueOf(cl.mode()); + return cl.mode().name(); } throw new IllegalStateException("Unhandled leg type: " + leg); }; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java index 32ab5dba36a..5c19f6fc817 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java @@ -1,7 +1,6 @@ package org.opentripplanner.ext.carpooling.constraints; import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.time.Duration; @@ -10,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.ext.carpooling.MockGraphPathFactory; -import org.opentripplanner.ext.carpooling.routing.RoutePoint; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.state.State; @@ -26,12 +24,6 @@ void setup() { @Test void satisfiesConstraints_noExistingStops_alwaysAccepts() { - // Route with only boarding and alighting (no stops) - List originalPoints = List.of( - new RoutePoint(OSLO_CENTER, "Boarding"), - new RoutePoint(OSLO_NORTH, "Alighting") - ); - Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10) }; // Modified route with passenger inserted @@ -47,13 +39,6 @@ void satisfiesConstraints_noExistingStops_alwaysAccepts() { @Test void satisfiesConstraints_delayWellUnderThreshold_accepts() { - // Original route: boarding -> stop1 -> alighting - List originalPoints = List.of( - new RoutePoint(OSLO_CENTER, "Boarding"), - new RoutePoint(OSLO_EAST, "Stop1"), - new RoutePoint(OSLO_NORTH, "Alighting") - ); - // Original timings: 0min -> 5min -> 15min Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(5), Duration.ofMinutes(15) }; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java index 1f0e63eaa8f..66a82ec3463 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java @@ -62,14 +62,15 @@ void accepts_tripAroundLake_passengerOnSegment_returnsTrue() { } @Test - void accepts_passengerOutsideCorridor_returnsFalse() { + void accepts_passengerFarFromRoute_butDirectionallyAligned_returnsTrue() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - // Passenger far to the east, outside route corridor + // Passenger far to the east but directionally aligned (both going north) var passengerPickup = new WgsCoordinate(59.9139, 11.0000); // Way east var passengerDropoff = new WgsCoordinate(59.9439, 11.0000); - assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + // Should accept - only checks direction, not distance (that's DistanceBasedFilter's job) + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); } @Test @@ -163,55 +164,14 @@ void customBearingTolerance_rejectsOutsideCustomTolerance() { assertFalse(customFilter.accepts(trip, passengerPickup, passengerDropoff)); } - @Test - void customCorridorTolerance_acceptsWithinWiderCorridor() { - // Custom filter with very wide corridor (0.5° ≈ 55km) - var customFilter = new DirectionalCompatibilityFilter(60.0, 0.5); - - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - // Passenger far east but directionally aligned - var passengerPickup = new WgsCoordinate(59.920, 11.2); // Far east - var passengerDropoff = new WgsCoordinate(59.950, 11.2); - - // Should accept with wide corridor - assertTrue(customFilter.accepts(trip, passengerPickup, passengerDropoff)); - } - - @Test - void customCorridorTolerance_rejectsOutsideNarrowCorridor() { - // Custom filter with narrow corridor (0.01° ≈ 1.1km) - var customFilter = new DirectionalCompatibilityFilter(60.0, 0.01); - - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - // Passenger slightly east and directionally aligned - var passengerPickup = new WgsCoordinate(59.920, 10.80); // Slightly east - var passengerDropoff = new WgsCoordinate(59.950, 10.80); - - // Should reject with narrow corridor - assertFalse(customFilter.accepts(trip, passengerPickup, passengerDropoff)); - } - @Test void getBearingToleranceDegrees_returnsConfiguredValue() { var customFilter = new DirectionalCompatibilityFilter(45.0); assertEquals(45.0, customFilter.getBearingToleranceDegrees()); } - @Test - void getCorridorToleranceDegrees_returnsConfiguredValue() { - var customFilter = new DirectionalCompatibilityFilter(60.0, 0.2); - assertEquals(0.2, customFilter.getCorridorToleranceDegrees()); - } - @Test void defaultBearingTolerance_is60Degrees() { assertEquals(60.0, filter.getBearingToleranceDegrees()); } - - @Test - void defaultCorridorTolerance_is0Point1Degrees() { - assertEquals(0.1, filter.getCorridorToleranceDegrees()); - } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java index 0b0cf9565cd..c2307107438 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java @@ -70,7 +70,7 @@ void rejects_passengerInDifferentCity_returnsFalse() { } @Test - void rejects_oneLocationNear_otherLocationFar_returnsFalse() { + void rejects_oneLocationNear_otherLocationFar_returnsTrue() { // Simple horizontal trip (east-west, same latitude) var tripStart = new WgsCoordinate(59.9, 10.70); var tripEnd = new WgsCoordinate(59.9, 10.80); @@ -81,24 +81,8 @@ void rejects_oneLocationNear_otherLocationFar_returnsFalse() { var passengerPickup = new WgsCoordinate(59.9, 10.75); // On route var passengerDropoff = new WgsCoordinate(59.9 + 0.5, 10.75); // Far north - // Should reject because BOTH locations must be near the route - assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); - } - - @Test - void rejects_pickupFar_dropoffNear_returnsFalse() { - // Simple horizontal trip (east-west, same latitude) - var tripStart = new WgsCoordinate(59.9, 10.70); - var tripEnd = new WgsCoordinate(59.9, 10.80); - var trip = createSimpleTrip(tripStart, tripEnd); - - // Pickup far to the north (>50km perpendicular), dropoff on the route - // At this latitude, 0.5° latitude ≈ 55km - var passengerPickup = new WgsCoordinate(59.9 + 0.5, 10.75); // Far north - var passengerDropoff = new WgsCoordinate(59.9, 10.75); // On route - - // Should reject because BOTH locations must be near the route - assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + // Should accept because only one location must be near the route + assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); } @Test @@ -213,18 +197,17 @@ void accepts_horizontalRoute_passengerAlongRoute_returnsTrue() { } @Test - void accepts_tripWithMultipleStops_passengerNearMainRoute() { - // Trip with multiple stops - filter only looks at boarding to alighting line + void accepts_tripWithMultipleStops_passengerNearAnySegment() { + // Trip with multiple stops - filter checks ALL segments var stop1 = createStopAt(0, LAKE_EAST); var stop2 = createStopAt(1, LAKE_SOUTH); var trip = createTripWithStops(LAKE_NORTH, java.util.List.of(stop1, stop2), LAKE_WEST); - // Passenger journey near the direct line from LAKE_NORTH to LAKE_WEST - // Even though actual route goes through EAST and SOUTH - var passengerPickup = new WgsCoordinate(59.9239, 10.735); // Between NORTH and WEST - var passengerDropoff = new WgsCoordinate(59.9239, 10.720); + // Passenger journey near the LAKE_SOUTH to LAKE_WEST segment + var passengerPickup = new WgsCoordinate(59.9139, 10.735); // Near SOUTH + var passengerDropoff = new WgsCoordinate(59.9139, 10.720); // Near WEST - // Should accept if close to the direct line (boarding to alighting) + // Should accept if close to any segment of the route assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java index 8176552e471..8a231214917 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java @@ -27,7 +27,9 @@ void accepts_passengerRequestWithinTimeWindow_returnsTrue() { // Passenger requests at 10:15 (15 minutes after trip departure) var passengerRequestTime = tripDepartureTime.plusMinutes(15).toInstant(); - assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertTrue( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -38,7 +40,9 @@ void accepts_passengerRequestExactlyAtTripDeparture_returnsTrue() { // Passenger requests exactly when trip departs var passengerRequestTime = tripDepartureTime.toInstant(); - assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertTrue( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -49,7 +53,9 @@ void accepts_passengerRequestAtWindowBoundary_returnsTrue() { // Passenger requests exactly 30 minutes after (at boundary) var passengerRequestTime = tripDepartureTime.plusMinutes(30).toInstant(); - assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertTrue( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -60,7 +66,9 @@ void accepts_passengerRequestBeforeTripDeparture_withinWindow_returnsTrue() { // Passenger requests 20 minutes before trip departs (within 30-min window) var passengerRequestTime = tripDepartureTime.minusMinutes(20).toInstant(); - assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertTrue( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -71,7 +79,9 @@ void rejects_passengerRequestTooFarInFuture_returnsFalse() { // Passenger requests 45 minutes after trip departs (outside 30-min window) var passengerRequestTime = tripDepartureTime.plusMinutes(45).toInstant(); - assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertFalse( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -82,7 +92,9 @@ void rejects_passengerRequestTooFarInPast_returnsFalse() { // Passenger requests 45 minutes before trip departs (outside 30-min window) var passengerRequestTime = tripDepartureTime.minusMinutes(45).toInstant(); - assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertFalse( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -93,35 +105,9 @@ void rejects_passengerRequestWayTooLate_returnsFalse() { // Passenger requests 2 hours after trip departs var passengerRequestTime = tripDepartureTime.plusHours(2).toInstant(); - assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); - } - - @Test - void customTimeWindow_acceptsWithinCustomWindow() { - // Custom filter with 60-minute window - var customFilter = new TimeBasedFilter(Duration.ofMinutes(60)); - - var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); - var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); - - // Passenger requests 50 minutes after (within 60-min window, outside default 30-min) - var passengerRequestTime = tripDepartureTime.plusMinutes(50).toInstant(); - - assertTrue(customFilter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); - } - - @Test - void customTimeWindow_rejectsOutsideCustomWindow() { - // Custom filter with 10-minute window - var customFilter = new TimeBasedFilter(Duration.ofMinutes(10)); - - var tripDepartureTime = ZonedDateTime.parse("2024-01-15T10:00:00+01:00"); - var trip = createSimpleTripWithTime(OSLO_CENTER, OSLO_NORTH, tripDepartureTime); - - // Passenger requests 15 minutes after (outside 10-min window) - var passengerRequestTime = tripDepartureTime.plusMinutes(15).toInstant(); - - assertFalse(customFilter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime)); + assertFalse( + filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST, passengerRequestTime, Duration.ofMinutes(30)) + ); } @Test @@ -132,15 +118,4 @@ void acceptsWithoutTimeParameter_alwaysReturnsTrue() { // When called without time parameter, should accept (with warning log) assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_NORTHEAST)); } - - @Test - void getTimeWindow_returnsConfiguredWindow() { - var customFilter = new TimeBasedFilter(Duration.ofMinutes(45)); - assertEquals(Duration.ofMinutes(45), customFilter.getTimeWindow()); - } - - @Test - void defaultTimeWindow_is30Minutes() { - assertEquals(Duration.ofMinutes(30), filter.getTimeWindow()); - } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java new file mode 100644 index 00000000000..676ba3268a9 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java @@ -0,0 +1,125 @@ +package org.opentripplanner.ext.carpooling.model; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for capacity checking methods on {@link CarpoolTrip}. + */ +class CarpoolTripCapacityTest { + + @Test + void getPassengerCountAtPosition_noStops_allZeros() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + assertEquals(0, trip.getPassengerCountAtPosition(0)); // Boarding + assertEquals(0, trip.getPassengerCountAtPosition(1)); // Beyond stops + } + + @Test + void getPassengerCountAtPosition_onePickupStop_incrementsAtStop() { + var stop1 = createStop(0, +1); // Pickup 1 passenger + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + assertEquals(0, trip.getPassengerCountAtPosition(0)); // Before stop + assertEquals(1, trip.getPassengerCountAtPosition(1)); // After stop + assertEquals(0, trip.getPassengerCountAtPosition(2)); // Alighting + } + + @Test + void getPassengerCountAtPosition_pickupAndDropoff_incrementsThenDecrements() { + var stop1 = createStop(0, +2); // Pickup 2 passengers + var stop2 = createStop(1, -1); // Dropoff 1 passenger + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + assertEquals(0, trip.getPassengerCountAtPosition(0)); // Before any stops + assertEquals(2, trip.getPassengerCountAtPosition(1)); // After first pickup + assertEquals(1, trip.getPassengerCountAtPosition(2)); // After dropoff + assertEquals(0, trip.getPassengerCountAtPosition(3)); // Alighting + } + + @Test + void getPassengerCountAtPosition_multipleStops_cumulativeCount() { + var stop1 = createStop(0, +1); + var stop2 = createStop(1, +2); + var stop3 = createStop(2, -1); + var stop4 = createStop(3, +1); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3, stop4), OSLO_NORTH); + + assertEquals(0, trip.getPassengerCountAtPosition(0)); + assertEquals(1, trip.getPassengerCountAtPosition(1)); // 0 + 1 + assertEquals(3, trip.getPassengerCountAtPosition(2)); // 1 + 2 + assertEquals(2, trip.getPassengerCountAtPosition(3)); // 3 - 1 + assertEquals(3, trip.getPassengerCountAtPosition(4)); // 2 + 1 + assertEquals(0, trip.getPassengerCountAtPosition(5)); // Alighting + } + + @Test + void getPassengerCountAtPosition_negativePosition_throwsException() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(-1)); + } + + @Test + void hasCapacityForInsertion_noPassengers_hasCapacity() { + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); + + assertTrue(trip.hasCapacityForInsertion(1, 2, 1)); + assertTrue(trip.hasCapacityForInsertion(1, 2, 4)); // Can fit all 4 seats + } + + @Test + void hasCapacityForInsertion_fullCapacity_noCapacity() { + var stop1 = createStop(0, +4); // Fill all 4 seats + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + // No room for additional passenger after stop 1 + assertFalse(trip.hasCapacityForInsertion(2, 3, 1)); + } + + @Test + void hasCapacityForInsertion_partialCapacity_hasCapacityForOne() { + var stop1 = createStop(0, +3); // 3 of 4 seats taken + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + assertTrue(trip.hasCapacityForInsertion(2, 3, 1)); // Room for 1 + assertFalse(trip.hasCapacityForInsertion(2, 3, 2)); // No room for 2 + } + + @Test + void hasCapacityForInsertion_acrossMultiplePositions_checksAll() { + var stop1 = createStop(0, +2); + var stop2 = createStop(1, +1); // Total 3 passengers at position 2 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + // Range 1-3 includes position with 3 passengers, so only 1 seat available + assertTrue(trip.hasCapacityForInsertion(1, 3, 1)); + assertFalse(trip.hasCapacityForInsertion(1, 3, 2)); + } + + @Test + void hasCapacityForInsertion_rangeBeforeStop_usesInitialCapacity() { + var stop1 = createStop(0, +4); // Fill capacity at position 1 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + // Pickup at position 1, dropoff at position 1 - only checks capacity at boarding (position 0) + // At boarding there are no passengers yet, so we have full capacity + assertTrue(trip.hasCapacityForInsertion(1, 1, 4)); + } + + @Test + void hasCapacityForInsertion_capacityFreesUpInRange_checksMaxInRange() { + var stop1 = createStop(0, +3); // 3 passengers + var stop2 = createStop(1, -2); // 2 dropoff, leaving 1 + var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + // Range includes both positions - max passengers is 3 (at position 1) + assertTrue(trip.hasCapacityForInsertion(1, 3, 1)); // 4 total - 3 max = 1 available + assertFalse(trip.hasCapacityForInsertion(1, 3, 2)); // Not enough + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java new file mode 100644 index 00000000000..f0dc61fe1da --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -0,0 +1,352 @@ +package org.opentripplanner.ext.carpooling.routing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; +import org.opentripplanner.ext.carpooling.routing.InsertionEvaluator.RoutingFunction; +import org.opentripplanner.ext.carpooling.util.BeelineEstimator; + +class InsertionEvaluatorTest { + + private RoutingFunction mockRoutingFunction; + private PassengerDelayConstraints delayConstraints; + private InsertionPositionFinder positionFinder; + private InsertionEvaluator evaluator; + + @BeforeEach + void setup() { + mockRoutingFunction = mock(RoutingFunction.class); + delayConstraints = new PassengerDelayConstraints(); + positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator()); + evaluator = new InsertionEvaluator(mockRoutingFunction, delayConstraints); + } + + /** + * Helper method that mimics the old findOptimalInsertion() behavior for backwards compatibility in tests. + * This explicitly performs position finding followed by evaluation. + */ + private InsertionCandidate findOptimalInsertion( + org.opentripplanner.ext.carpooling.model.CarpoolTrip trip, + org.opentripplanner.framework.geometry.WgsCoordinate passengerPickup, + org.opentripplanner.framework.geometry.WgsCoordinate passengerDropoff + ) { + List viablePositions = positionFinder.findViablePositions( + trip, + passengerPickup, + passengerDropoff + ); + + if (viablePositions.isEmpty()) { + return null; + } + + return evaluator.findBestInsertion(trip, viablePositions, passengerPickup, passengerDropoff); + } + + @Test + void findOptimalInsertion_noValidPositions_returnsNull() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNull(result); + } + + @Test + void findOptimalInsertion_oneValidPosition_returnsCandidate() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(); + + // Mock routing to return valid paths + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + assertEquals(1, result.pickupPosition()); + assertEquals(2, result.dropoffPosition()); + } + + @Test + void findOptimalInsertion_routingFails_skipsPosition() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + + // Routing sequence: + // 1. Baseline calculation (1 segment: OSLO_CENTER → OSLO_NORTH) = mockPath + // 2. First insertion attempt fails (null, null, null for 3 segments) + // 3. Second insertion attempt succeeds (mockPath for all 3 segments) + when(mockRoutingFunction.route(any(), any())) + .thenReturn(mockPath) // Baseline + .thenReturn(null) // First insertion - segment 1 fails + .thenReturn(mockPath) // Second insertion - all segments succeed + .thenReturn(mockPath) + .thenReturn(mockPath); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + // Should skip failed routing and find a valid one + assertNotNull(result); + } + + @Test + void findOptimalInsertion_exceedsDeviationBudget_returnsNull() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + // Create routing that results in excessive additional time + // Baseline is 2 segments * 5 min = 10 min + // Modified route is 3 segments * 20 min = 60 min + // Additional = 50 min, exceeds 5 min budget + var mockPath = createMockGraphPath(Duration.ofMinutes(20)); + + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + // Should not return candidate that exceeds budget + assertNull(result); + } + + @Test + void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { + var stop1 = createStopAt(0, OSLO_EAST); + var stop2 = createStopAt(1, OSLO_WEST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(); + + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + } + + @Test + void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Routing returns null (failure) for baseline calculation + when(mockRoutingFunction.route(any(), any())).thenReturn(null); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNull(result); + } + + @Test + void findOptimalInsertion_selectsMinimumAdditionalDuration() { + var trip = createTripWithDeviationBudget(Duration.ofMinutes(20), OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + // Baseline: 1 segment (CENTER → NORTH) at 10 min + // The algorithm will try multiple pickup/dropoff positions + // We'll use Answer to return different durations based on segment index + var mockPath10 = createMockGraphPath(Duration.ofMinutes(10)); + var mockPath4 = createMockGraphPath(Duration.ofMinutes(4)); + var mockPath6 = createMockGraphPath(Duration.ofMinutes(6)); + var mockPath5 = createMockGraphPath(Duration.ofMinutes(5)); + var mockPath7 = createMockGraphPath(Duration.ofMinutes(7)); + + // Use thenAnswer to provide consistent route times + // Just return paths with reasonable durations for all calls + when(mockRoutingFunction.route(any(), any())) + .thenReturn(mockPath10) // Baseline + .thenReturn(mockPath4, mockPath5, mockPath6) // First insertion (15 min total, 5 min additional) + .thenReturn(mockPath5, mockPath6, mockPath7); // Second insertion (18 min total, 8 min additional) + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + // Should have selected one of the evaluated insertions + // The exact additional duration depends on which position was evaluated first + assertTrue(result.additionalDuration().compareTo(Duration.ofMinutes(20)) <= 0); + assertTrue(result.additionalDuration().compareTo(Duration.ZERO) > 0); + } + + @Test + void findOptimalInsertion_simpleTrip_hasExpectedStructure() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths BEFORE any when() statements + var mockPath = createMockGraphPath(); + + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + assertNotNull(result.trip()); + assertNotNull(result.routeSegments()); + assertFalse(result.routeSegments().isEmpty()); + assertTrue(result.pickupPosition() >= 0); + assertTrue(result.dropoffPosition() > result.pickupPosition()); + } + + @Test + void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { + // This test catches the bug where segments were incorrectly reused + // Scenario: Trip A→B, insert passenger C→D where both C and D are between A and B + // Expected: All 3 segments (A→C, C→D, D→B) should be routed, not reused + + // Create a simple 2-point trip (OSLO_CENTER → OSLO_NORTH) + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Create mock paths with DISTINCT durations for verification + // Baseline: 1 segment (CENTER → NORTH) = 10 min + var baselinePath = createMockGraphPath(Duration.ofMinutes(10)); + + // Modified route segments should have DIFFERENT durations + // If baseline is incorrectly reused, we'd see 10 min for A→C segment + var segmentAC = createMockGraphPath(Duration.ofMinutes(3)); // CENTER → EAST + var segmentCD = createMockGraphPath(Duration.ofMinutes(2)); // EAST → MIDPOINT_NORTH + var segmentDB = createMockGraphPath(Duration.ofMinutes(4)); // MIDPOINT_NORTH → NORTH + + // Setup routing mock: return all segment mocks for any routing call + // The algorithm will evaluate multiple insertion positions + when(mockRoutingFunction.route(any(), any())).thenReturn( + baselinePath, + segmentAC, + segmentCD, + segmentDB, + segmentAC, + segmentCD, + segmentDB, + segmentAC, + segmentCD + ); + + // Passenger pickup at OSLO_EAST, dropoff at OSLO_MIDPOINT_NORTH + // Both are between OSLO_CENTER and OSLO_NORTH + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_MIDPOINT_NORTH); + + assertNotNull(result, "Should find valid insertion"); + + // Verify the result structure + assertEquals(3, result.routeSegments().size(), "Should have 3 segments in modified route"); + assertEquals(Duration.ofMinutes(10), result.baselineDuration(), "Baseline should be 10 min"); + + // CRITICAL: Total duration should be sum of NEW segments, NOT baseline duration + // Total = 3 + 2 + 4 = 9 minutes + // If bug exists, segment A→C would incorrectly use baseline (10 min) → total would be wrong + Duration expectedTotal = Duration.ofMinutes(9); + assertEquals( + expectedTotal, + result.totalDuration(), + "Total duration should be sum of newly routed segments" + ); + + // Additional duration should be negative (this insertion is actually faster!) + // This is realistic for insertions that "shortcut" part of the baseline route + Duration expectedAdditional = Duration.ofMinutes(-1); + assertEquals( + expectedAdditional, + result.additionalDuration(), + "Additional duration should be -1 minute (insertion is faster)" + ); + + // Verify routing was called at least 4 times (1 baseline + 3 new segments minimum) + // May be more due to evaluating multiple positions + verify(mockRoutingFunction, atLeast(4)).route(any(), any()); + } + + @Test + void findOptimalInsertion_insertAtEnd_reusesMostSegments() { + // This test verifies that segment reuse optimization still works correctly + // Scenario: Trip A→B→C, insert passenger that allows some segment reuse + // Expected: Segments that have matching endpoints should be REUSED + + var stop1 = createStopAt(0, OSLO_EAST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + // Baseline has 2 segments: CENTER→EAST, EAST→NORTH + var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + + // Return mock paths for all routing calls (baseline + any new segments) + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + // Insert passenger - the algorithm will find the best position + var result = findOptimalInsertion(trip, OSLO_WEST, OSLO_SOUTH); + + assertNotNull(result, "Should find valid insertion"); + + // Baseline should be calculated correctly + assertEquals(Duration.ofMinutes(10), result.baselineDuration()); + + // The modified route should have more segments than baseline + assertTrue( + result.routeSegments().size() >= 2, + "Modified route should have at least baseline segments" + ); + + // Additional duration should be positive (adding detour) + assertTrue( + result.additionalDuration().compareTo(Duration.ZERO) > 0, + "Adding passenger should increase duration" + ); + + // Verify that routing was called for baseline and new segments + // If all segments were re-routed, we'd see many more calls + // The exact number depends on which position is optimal and how many segments can be reused + verify(mockRoutingFunction, atLeast(2)).route(any(), any()); + } + + @Test + void findOptimalInsertion_pickupAtExistingPoint_handlesCorrectly() { + // Scenario: Trip A→B→C, passenger pickup at B (existing point), dropoff at new point + // Expected: Segment A→B should be reused, B→dropoff and dropoff→C should be routed + + var stop1 = createStopAt(0, OSLO_EAST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + + var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + // Pickup exactly at OSLO_EAST (existing stop), dropoff at OSLO_MIDPOINT_NORTH (new) + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_MIDPOINT_NORTH); + + assertNotNull(result, "Should find valid insertion"); + + // Modified route should have new segments + assertTrue(result.routeSegments().size() >= 2); + + // Routing should be called for baseline and new segments + verify(mockRoutingFunction, atLeast(2)).route(any(), any()); + } + + @Test + void findOptimalInsertion_singleSegmentTrip_routesAllNewSegments() { + // Edge case: Simplest possible trip (2 points, 1 segment) + // Any insertion will require routing all new segments + + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + + when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + + assertNotNull(result); + assertEquals(3, result.routeSegments().size()); + + // Verify routing was called for baseline and new segments + verify(mockRoutingFunction, atLeast(4)).route(any(), any()); + + // Total duration should be positive + assertTrue(result.totalDuration().compareTo(Duration.ZERO) > 0); + assertTrue(result.baselineDuration().compareTo(Duration.ZERO) > 0); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java new file mode 100644 index 00000000000..df8c320f873 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java @@ -0,0 +1,99 @@ +package org.opentripplanner.ext.carpooling.routing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.opentripplanner.ext.carpooling.TestFixtures.*; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; +import org.opentripplanner.ext.carpooling.util.BeelineEstimator; + +/** + * Tests for {@link InsertionPositionFinder}. + * Focuses on heuristic validation: capacity, directional compatibility, and beeline delays. + */ +class InsertionPositionFinderTest { + + private InsertionPositionFinder finder; + + @BeforeEach + void setup() { + finder = new InsertionPositionFinder(); + } + + @Test + void findViablePositions_simpleTrip_findsPositions() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + var viablePositions = finder.findViablePositions(trip, OSLO_EAST, OSLO_WEST); + + assertFalse(viablePositions.isEmpty()); + // Simple trip (2 points) allows insertions at positions (1,2) and (1,3) + assertTrue(viablePositions.size() >= 1); + } + + @Test + void findViablePositions_incompatibleDirection_rejectsPosition() { + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + + // Passenger going perpendicular (EAST to WEST when trip is CENTER to NORTH) + // This should result in some positions being rejected by directional checks + var viablePositions = finder.findViablePositions(trip, OSLO_SOUTH, OSLO_CENTER); + + // May not be completely empty, but should have fewer positions than compatible directions + // The directional check filters out positions that cause too much backtracking + assertNotNull(viablePositions); + } + + @Test + void findViablePositions_noCapacity_rejectsPosition() { + // Create a trip with 0 available seats + var trip = createTripWithCapacity(0, OSLO_CENTER, List.of(), OSLO_NORTH); + + var viablePositions = finder.findViablePositions(trip, OSLO_EAST, OSLO_WEST); + + // Should reject all positions due to capacity + assertTrue(viablePositions.isEmpty()); + } + + @Test + void findViablePositions_exceedsBeelineDelay_rejectsPosition() { + // Create finder with very restrictive delay constraints + var restrictiveConstraints = new PassengerDelayConstraints(Duration.ofSeconds(1)); + var restrictiveFinder = new InsertionPositionFinder( + restrictiveConstraints, + new BeelineEstimator() + ); + + var trip = createTripWithStops(OSLO_CENTER, List.of(createStopAt(0, OSLO_EAST)), OSLO_NORTH); + + // Try to insert passenger that would cause significant detour + var viablePositions = restrictiveFinder.findViablePositions( + trip, + OSLO_WEST, // Far from route + OSLO_SOUTH // Even farther + ); + + // With very restrictive constraints, positions causing significant detours should be rejected + // However, the beeline check only applies if there are existing stops (routePoints.size() > 2) + // With CENTER, EAST, NORTH we have 3 points, so the check should apply + // The result depends on the actual distances and heuristics + assertNotNull(viablePositions); + } + + @Test + void findViablePositions_multipleStops_checksAllCombinations() { + var stop1 = createStopAt(0, OSLO_EAST); + var stop2 = createStopAt(1, OSLO_WEST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + var viablePositions = finder.findViablePositions(trip, OSLO_SOUTH, OSLO_NORTH); + + // Should evaluate multiple pickup/dropoff combinations + // Exact count depends on directional and beeline filtering + assertNotNull(viablePositions); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java deleted file mode 100644 index 2650395a0f2..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/OptimalInsertionStrategyTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package org.opentripplanner.ext.carpooling.routing; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.*; -import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import java.time.Duration; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.carpooling.routing.OptimalInsertionStrategy.RoutingFunction; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationResult; - -class OptimalInsertionStrategyTest { - - private InsertionValidator mockValidator; - private RoutingFunction mockRoutingFunction; - private OptimalInsertionStrategy strategy; - - @BeforeEach - void setup() { - mockValidator = mock(InsertionValidator.class); - mockRoutingFunction = mock(RoutingFunction.class); - strategy = new OptimalInsertionStrategy(mockValidator, mockRoutingFunction); - } - - @Test - void findOptimalInsertion_noValidPositions_returnsNull() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - // Validator rejects all positions - when(mockValidator.validate(any())).thenReturn(ValidationResult.invalid("Test reject")); - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - assertNull(result); - } - - @Test - void findOptimalInsertion_oneValidPosition_returnsCandidate() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - // Create mock paths BEFORE any when() statements - var mockPath = createMockGraphPath(); - - // Accept one specific position (null-safe matcher) - when( - mockValidator.validate( - argThat(ctx -> ctx != null && ctx.pickupPosition() == 1 && ctx.dropoffPosition() == 2) - ) - ).thenReturn(ValidationResult.valid()); - - when( - mockValidator.validate( - argThat(ctx -> ctx == null || ctx.pickupPosition() != 1 || ctx.dropoffPosition() != 2) - ) - ).thenReturn(ValidationResult.invalid("Wrong position")); - - // Mock routing to return valid paths - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - assertNotNull(result); - assertEquals(1, result.pickupPosition()); - assertEquals(2, result.dropoffPosition()); - } - - @Test - void findOptimalInsertion_routingFails_skipsPosition() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - // Create mock paths BEFORE any when() statements - var mockPath = createMockGraphPath(Duration.ofMinutes(5)); - - when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); - - // Routing sequence: - // 1. Baseline calculation (1 segment: OSLO_CENTER → OSLO_NORTH) = mockPath - // 2. First insertion attempt fails (null, null, null for 3 segments) - // 3. Second insertion attempt succeeds (mockPath for all 3 segments) - when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath) // Baseline - .thenReturn(null) // First insertion - segment 1 fails - .thenReturn(mockPath) // Second insertion - all segments succeed - .thenReturn(mockPath) - .thenReturn(mockPath); - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - // Should skip failed routing and find a valid one - assertNotNull(result); - } - - @Test - void findOptimalInsertion_exceedsDeviationBudget_returnsNull() { - var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); - - // Create mock paths BEFORE any when() statements - // Create routing that results in excessive additional time - // Baseline is 2 segments * 5 min = 10 min - // Modified route is 3 segments * 20 min = 60 min - // Additional = 50 min, exceeds 5 min budget - var mockPath = createMockGraphPath(Duration.ofMinutes(20)); - - when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - // Should not return candidate that exceeds budget - assertNull(result); - } - - @Test - void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { - var stop1 = createStopAt(0, OSLO_EAST); - var stop2 = createStopAt(1, OSLO_WEST); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - - // Create mock paths BEFORE any when() statements - var mockPath = createMockGraphPath(); - - when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); - - var result = strategy.findOptimalInsertion(trip, OSLO_SOUTH, OSLO_EAST); - - // Should have evaluated multiple positions - // Verify validator was called multiple times - verify(mockValidator, atLeast(3)).validate(any()); - } - - @Test - void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); - - // Routing returns null (failure) for baseline calculation - when(mockRoutingFunction.route(any(), any())).thenReturn(null); - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - assertNull(result); - } - - @Test - void findOptimalInsertion_selectsMinimumAdditionalDuration() { - var trip = createTripWithDeviationBudget(Duration.ofMinutes(20), OSLO_CENTER, OSLO_NORTH); - - // Create mock paths BEFORE any when() statements - // Baseline: 1 segment (CENTER → NORTH) at 10 min - // The algorithm will try multiple pickup/dropoff positions - // We'll use Answer to return different durations based on segment index - var mockPath10 = createMockGraphPath(Duration.ofMinutes(10)); - var mockPath4 = createMockGraphPath(Duration.ofMinutes(4)); - var mockPath6 = createMockGraphPath(Duration.ofMinutes(6)); - var mockPath5 = createMockGraphPath(Duration.ofMinutes(5)); - var mockPath7 = createMockGraphPath(Duration.ofMinutes(7)); - - when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); - - // Use thenAnswer to provide consistent route times - // Just return paths with reasonable durations for all calls - when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath10) // Baseline - .thenReturn(mockPath4, mockPath5, mockPath6) // First insertion (15 min total, 5 min additional) - .thenReturn(mockPath5, mockPath6, mockPath7); // Second insertion (18 min total, 8 min additional) - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - assertNotNull(result); - // Should have selected one of the evaluated insertions - // The exact additional duration depends on which position was evaluated first - assertTrue(result.additionalDuration().compareTo(Duration.ofMinutes(20)) <= 0); - assertTrue(result.additionalDuration().compareTo(Duration.ZERO) > 0); - } - - @Test - void findOptimalInsertion_simpleTrip_hasExpectedStructure() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - - // Create mock paths BEFORE any when() statements - var mockPath = createMockGraphPath(); - - when(mockValidator.validate(any())).thenReturn(ValidationResult.valid()); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); - - var result = strategy.findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); - - assertNotNull(result); - assertNotNull(result.trip()); - assertNotNull(result.routeSegments()); - assertFalse(result.routeSegments().isEmpty()); - assertTrue(result.pickupPosition() >= 0); - assertTrue(result.dropoffPosition() > result.pickupPosition()); - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java deleted file mode 100644 index d552d370b53..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/RoutePointTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.opentripplanner.ext.carpooling.routing; - -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import org.junit.jupiter.api.Test; - -class RoutePointTest { - - @Test - void constructor_validInputs_createsInstance() { - var point = new RoutePoint(OSLO_CENTER, "Test Point"); - - assertEquals(OSLO_CENTER, point.coordinate()); - assertEquals("Test Point", point.label()); - } - - @Test - void constructor_nullCoordinate_throwsException() { - assertThrows(IllegalArgumentException.class, () -> new RoutePoint(null, "Test")); - } - - @Test - void constructor_nullLabel_throwsException() { - assertThrows(IllegalArgumentException.class, () -> new RoutePoint(OSLO_CENTER, null)); - } - - @Test - void constructor_blankLabel_throwsException() { - assertThrows(IllegalArgumentException.class, () -> new RoutePoint(OSLO_CENTER, " ")); - } - - @Test - void constructor_emptyLabel_throwsException() { - assertThrows(IllegalArgumentException.class, () -> new RoutePoint(OSLO_CENTER, "")); - } - - @Test - void toString_includesLabelAndCoordinates() { - var point = new RoutePoint(OSLO_CENTER, "Oslo"); - var str = point.toString(); - - assertTrue(str.contains("Oslo")); - assertTrue(str.contains("59.91")); // Partial coordinate - assertTrue(str.contains("10.75")); - } - - @Test - void equals_sameValues_returnsTrue() { - var point1 = new RoutePoint(OSLO_CENTER, "Test"); - var point2 = new RoutePoint(OSLO_CENTER, "Test"); - - assertEquals(point1, point2); - } - - @Test - void equals_differentCoordinates_returnsFalse() { - var point1 = new RoutePoint(OSLO_CENTER, "Test"); - var point2 = new RoutePoint(OSLO_NORTH, "Test"); - - assertNotEquals(point1, point2); - } - - @Test - void equals_differentLabels_returnsFalse() { - var point1 = new RoutePoint(OSLO_CENTER, "Test1"); - var point2 = new RoutePoint(OSLO_CENTER, "Test2"); - - assertNotEquals(point1, point2); - } - - @Test - void hashCode_sameValues_returnsSameHash() { - var point1 = new RoutePoint(OSLO_CENTER, "Test"); - var point2 = new RoutePoint(OSLO_CENTER, "Test"); - - assertEquals(point1.hashCode(), point2.hashCode()); - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java index 7d57d34d991..7a89e072918 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java @@ -4,8 +4,6 @@ import static org.opentripplanner.ext.carpooling.TestFixtures.*; import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.carpooling.util.DirectionalCalculator.DirectionalAlignment; -import org.opentripplanner.framework.geometry.WgsCoordinate; class DirectionalCalculatorTest { @@ -65,161 +63,4 @@ void bearingDifference_reverse_returnsShortestAngle() { double diff2 = DirectionalCalculator.bearingDifference(350.0, 10.0); assertEquals(diff1, diff2, 0.01); } - - @Test - void isDirectionallyCompatible_sameDirection_returnsTrue() { - // Both going north - boolean compatible = DirectionalCalculator.isDirectionallyCompatible( - OSLO_CENTER, - OSLO_NORTH, // Trip: north - OSLO_EAST, - new WgsCoordinate(59.9549, 10.7922), // Passenger: also north - 60.0 - ); - assertTrue(compatible); - } - - @Test - void isDirectionallyCompatible_oppositeDirection_returnsFalse() { - // Trip north, passenger south - boolean compatible = DirectionalCalculator.isDirectionallyCompatible( - OSLO_CENTER, - OSLO_NORTH, // Trip: north - OSLO_EAST, - OSLO_CENTER, // Passenger: west/southwest - 60.0 - ); - assertFalse(compatible); - } - - @Test - void isDirectionallyCompatible_withinTolerance_returnsTrue() { - // Trip going north, passenger going slightly northeast (within 60° tolerance) - boolean compatible = DirectionalCalculator.isDirectionallyCompatible( - OSLO_CENTER, - OSLO_NORTH, - OSLO_CENTER, - OSLO_NORTHEAST, // Northeast, ~45° from north - 60.0 - ); - assertTrue(compatible); - } - - @Test - void isDirectionallyCompatible_exceedsTolerance_returnsFalse() { - // Trip going north, passenger going east (90° difference) - boolean compatible = DirectionalCalculator.isDirectionallyCompatible( - OSLO_CENTER, - OSLO_NORTH, - OSLO_CENTER, - OSLO_EAST, // East, 90° from north - 60.0 // Tolerance too small - ); - assertFalse(compatible); - } - - @Test - void maintainsForwardProgress_straightLine_returnsTrue() { - // Inserting point along straight line maintains progress - boolean maintains = DirectionalCalculator.maintainsForwardProgress( - OSLO_CENTER, - OSLO_MIDPOINT_NORTH, // Midpoint north - OSLO_NORTH, - 90.0 - ); - assertTrue(maintains); - } - - @Test - void maintainsForwardProgress_backtracking_returnsFalse() { - // Inserting point behind causes backtracking - boolean maintains = DirectionalCalculator.maintainsForwardProgress( - OSLO_CENTER, - OSLO_SOUTH, // Point south when going north - OSLO_NORTH, - 90.0 - ); - assertFalse(maintains); - } - - @Test - void maintainsForwardProgress_moderateDetour_returnsTrue() { - // Slight eastward detour should be allowed - var slightlyEast = new WgsCoordinate(59.9289, 10.7622); - boolean maintains = DirectionalCalculator.maintainsForwardProgress( - OSLO_CENTER, - slightlyEast, - OSLO_NORTH, - 90.0 - ); - assertTrue(maintains); - } - - @Test - void maintainsForwardProgress_largeDetour_returnsFalse() { - // Large detour exceeds tolerance - boolean maintains = DirectionalCalculator.maintainsForwardProgress( - OSLO_CENTER, - OSLO_WEST, // Large westward detour when going north - OSLO_NORTH, - 45.0 // Strict tolerance - ); - assertFalse(maintains); - } - - @Test - void classify_highlyAligned_when10Degrees() { - // Very close directions - var alignment = DirectionalCalculator.classify( - OSLO_CENTER, - OSLO_NORTH, - OSLO_EAST, - new WgsCoordinate(59.9549, 10.7922) // Slightly north from east point - ); - assertEquals(DirectionalAlignment.HIGHLY_ALIGNED, alignment); - } - - @Test - void classify_aligned_when45Degrees() { - var alignment = DirectionalCalculator.classify( - OSLO_CENTER, - OSLO_NORTH, // North - OSLO_CENTER, - OSLO_NORTHEAST // Northeast (~45°) - ); - assertEquals(DirectionalAlignment.ALIGNED, alignment); - } - - @Test - void classify_divergent_when90Degrees() { - var alignment = DirectionalCalculator.classify( - OSLO_CENTER, - OSLO_NORTH, // North - OSLO_CENTER, - OSLO_EAST // East (90°) - ); - assertEquals(DirectionalAlignment.DIVERGENT, alignment); - } - - @Test - void classify_opposite_when180Degrees() { - var alignment = DirectionalCalculator.classify( - OSLO_CENTER, - OSLO_NORTH, // North - OSLO_CENTER, - OSLO_SOUTH // South (180°) - ); - assertEquals(DirectionalAlignment.OPPOSITE, alignment); - } - - @Test - void classify_withCustomThresholds_usesProvidedValues() { - var alignment = DirectionalCalculator.classify( - 45.0, // Bearing difference - 20.0, // Highly aligned threshold - 50.0, // Aligned threshold - 100.0 // Divergent threshold - ); - assertEquals(DirectionalAlignment.ALIGNED, alignment); // 45° fits in 20-50 range - } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java deleted file mode 100644 index c802928cd49..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/PassengerCountTimelineTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.opentripplanner.ext.carpooling.util; - -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import java.util.List; -import org.junit.jupiter.api.Test; - -class PassengerCountTimelineTest { - - @Test - void build_noStops_allZeros() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // No stops = no passengers along route - assertEquals(0, timeline.getPassengerCount(0)); - } - - @Test - void build_onePickupStop_incrementsAtStop() { - var stop1 = createStop(0, +1); // Pickup 1 passenger - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - assertEquals(0, timeline.getPassengerCount(0)); // Before stop - assertEquals(1, timeline.getPassengerCount(1)); // After stop - } - - @Test - void build_pickupAndDropoff_incrementsThenDecrements() { - var stop1 = createStop(0, +2); // Pickup 2 passengers - var stop2 = createStop(1, -1); // Dropoff 1 passenger - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - assertEquals(0, timeline.getPassengerCount(0)); // Before any stops - assertEquals(2, timeline.getPassengerCount(1)); // After first pickup - assertEquals(1, timeline.getPassengerCount(2)); // After dropoff - } - - @Test - void build_multipleStops_cumulativeCount() { - var stop1 = createStop(0, +1); - var stop2 = createStop(1, +2); - var stop3 = createStop(2, -1); - var stop4 = createStop(3, +1); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3, stop4), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - assertEquals(0, timeline.getPassengerCount(0)); - assertEquals(1, timeline.getPassengerCount(1)); // 0 + 1 - assertEquals(3, timeline.getPassengerCount(2)); // 1 + 2 - assertEquals(2, timeline.getPassengerCount(3)); // 3 - 1 - assertEquals(3, timeline.getPassengerCount(4)); // 2 + 1 - } - - @Test - void build_negativePassengerDelta_handlesDropoffs() { - var stop1 = createStop(0, +3); // Pickup 3 - var stop2 = createStop(1, -3); // Dropoff all 3 - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - assertEquals(0, timeline.getPassengerCount(0)); - assertEquals(3, timeline.getPassengerCount(1)); - assertEquals(0, timeline.getPassengerCount(2)); // Back to zero - } - - @Test - void hasCapacityInRange_noPassengers_hasCapacity() { - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - assertTrue(timeline.hasCapacityInRange(0, 1, 1)); - assertTrue(timeline.hasCapacityInRange(0, 1, 4)); // Can fit all 4 seats - } - - @Test - void hasCapacityInRange_fullCapacity_noCapacity() { - var stop1 = createStop(0, +4); // Fill all 4 seats - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // No room for additional passenger after stop 1 - assertFalse(timeline.hasCapacityInRange(1, 2, 1)); - } - - @Test - void hasCapacityInRange_partialCapacity_hasCapacityForOne() { - var stop1 = createStop(0, +3); // 3 of 4 seats taken - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - assertTrue(timeline.hasCapacityInRange(1, 2, 1)); // Room for 1 - assertFalse(timeline.hasCapacityInRange(1, 2, 2)); // No room for 2 - } - - @Test - void hasCapacityInRange_acrossMultiplePositions_checksAll() { - var stop1 = createStop(0, +2); - var stop2 = createStop(1, +1); // Total 3 passengers - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Range 1-3 includes position with 3 passengers, so only 1 seat available - assertTrue(timeline.hasCapacityInRange(1, 3, 1)); - assertFalse(timeline.hasCapacityInRange(1, 3, 2)); - } - - @Test - void hasCapacityInRange_rangeBeforeStop_usesInitialCapacity() { - var stop1 = createStop(0, +4); // Fill capacity at position 1 - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Before stop, should have full capacity - assertTrue(timeline.hasCapacityInRange(0, 1, 4)); - } - - @Test - void hasCapacityInRange_capacityFreesUpInRange_checksMaxInRange() { - var stop1 = createStop(0, +3); // 3 passengers - var stop2 = createStop(1, -2); // 2 dropoff, leaving 1 - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Range includes both positions - max passengers is 3 (at position 1) - assertTrue(timeline.hasCapacityInRange(1, 3, 1)); // 4 total - 3 max = 1 available - assertFalse(timeline.hasCapacityInRange(1, 3, 2)); // Not enough - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java deleted file mode 100644 index 4385e9751ae..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/RouteGeometryTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.opentripplanner.ext.carpooling.util; - -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import java.util.List; -import org.junit.jupiter.api.Test; -import org.opentripplanner.framework.geometry.WgsCoordinate; - -class RouteGeometryTest { - - @Test - void calculateBoundingBox_singlePoint_returnsPointBox() { - List route = List.of(OSLO_CENTER); - var bbox = RouteGeometry.calculateBoundingBox(route); - - assertEquals(OSLO_CENTER.latitude(), bbox.minLat()); - assertEquals(OSLO_CENTER.latitude(), bbox.maxLat()); - assertEquals(OSLO_CENTER.longitude(), bbox.minLon()); - assertEquals(OSLO_CENTER.longitude(), bbox.maxLon()); - } - - @Test - void calculateBoundingBox_twoPoints_returnsEnclosingBox() { - List route = List.of(OSLO_CENTER, OSLO_NORTH); - var bbox = RouteGeometry.calculateBoundingBox(route); - - assertEquals(OSLO_CENTER.latitude(), bbox.minLat()); - assertEquals(OSLO_NORTH.latitude(), bbox.maxLat()); - assertEquals(OSLO_CENTER.longitude(), bbox.minLon()); - assertEquals(OSLO_CENTER.longitude(), bbox.maxLon()); - } - - @Test - void calculateBoundingBox_multiplePoints_findsMinMax() { - List route = List.of(OSLO_CENTER, OSLO_NORTH, OSLO_EAST, OSLO_SOUTH, OSLO_WEST); - var bbox = RouteGeometry.calculateBoundingBox(route); - - assertEquals(OSLO_SOUTH.latitude(), bbox.minLat()); - assertEquals(OSLO_NORTH.latitude(), bbox.maxLat()); - assertEquals(OSLO_WEST.longitude(), bbox.minLon()); - assertEquals(OSLO_EAST.longitude(), bbox.maxLon()); - } - - @Test - void calculateBoundingBox_emptyList_throwsException() { - assertThrows(IllegalArgumentException.class, () -> - RouteGeometry.calculateBoundingBox(List.of()) - ); - } - - @Test - void areBothWithinCorridor_straightRoute_bothClose_returnsTrue() { - List route = List.of(OSLO_CENTER, OSLO_NORTH); - var pickup = new WgsCoordinate(59.9189, 10.7522); // Slightly north of center - var dropoff = new WgsCoordinate(59.9389, 10.7522); // Slightly south of north - - assertTrue(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); - } - - @Test - void areBothWithinCorridor_straightRoute_oneFar_returnsFalse() { - List route = List.of(OSLO_CENTER, OSLO_NORTH); - var pickup = new WgsCoordinate(59.9189, 10.7522); // Close - var dropoff = new WgsCoordinate(59.9189, 11.0000); // Far east - - assertFalse(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); - } - - @Test - void areBothWithinCorridor_bothOutside_returnsFalse() { - List route = List.of(OSLO_CENTER, OSLO_NORTH); - var pickup = new WgsCoordinate(59.9139, 11.0000); // Far east - var dropoff = new WgsCoordinate(59.9439, 11.0000); // Far east - - assertFalse(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); - } - - @Test - void areBothWithinCorridor_emptyRoute_returnsFalse() { - // Empty route should return false (or throw exception, both are acceptable) - try { - assertFalse(RouteGeometry.areBothWithinCorridor(List.of(), OSLO_CENTER, OSLO_NORTH)); - } catch (IllegalArgumentException e) { - // Also acceptable to throw exception for empty route - assertTrue(e.getMessage().contains("empty")); - } - } - - @Test - void areBothWithinCorridor_singlePointRoute_usesExpansion() { - List route = List.of(OSLO_CENTER); - // Points within expanded bounding box should return true - var pickup = new WgsCoordinate(59.9140, 10.7523); // Very close - var dropoff = new WgsCoordinate(59.9141, 10.7524); - - assertTrue(RouteGeometry.areBothWithinCorridor(route, pickup, dropoff)); - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java deleted file mode 100644 index db2b126ad58..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CapacityValidatorTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationContext; - -class CapacityValidatorTest { - - private CapacityValidator validator; - - @BeforeEach - void setup() { - validator = new CapacityValidator(); - } - - @Test - void validate_sufficientCapacity_returnsValid() { - var stop1 = createStop(0, +2); // 2 passengers - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); - - var context = new ValidationContext( - 1, - 2, // Pickup at 1, dropoff at 2 - OSLO_EAST, - OSLO_WEST, - routeCoords, - timeline - ); - - var result = validator.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_insufficientCapacityAtPickup_returnsInvalid() { - var stop1 = createStop(0, +4); // All 4 seats taken - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); - - var context = new ValidationContext( - 2, - 3, // Try to insert after stop (no capacity) - OSLO_EAST, - OSLO_WEST, - routeCoords, - timeline - ); - - var result = validator.validate(context); - assertFalse(result.isValid()); - assertTrue(result.reason().contains("capacity")); - } - - @Test - void validate_capacityFreedAtDropoff_checksRange() { - var stop1 = createStop(0, +3); // 3 passengers - var stop2 = createStop(1, -2); // 2 dropoff, leaving 1 - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_SOUTH, OSLO_NORTH); - - // Inserting between stop1 and stop2 (3 passengers) - only 1 seat free - var context = new ValidationContext(2, 3, OSLO_EAST, OSLO_WEST, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); // 1 seat available - } - - @Test - void validate_noCapacityInRange_returnsInvalid() { - var stop1 = createStop(0, +4); // Fill all capacity - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); - - // Try to insert after the stop where capacity is full - var context = new ValidationContext(2, 3, OSLO_EAST, OSLO_WEST, routeCoords, timeline); - - var result = validator.validate(context); - assertFalse(result.isValid()); - } - - @Test - void validate_capacityAtBeginning_beforeAnyStops_returnsValid() { - var stop1 = createStop(0, +3); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); - - // Insert before any stops (full capacity available) - var context = new ValidationContext(1, 2, OSLO_EAST, OSLO_WEST, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_exactlyAtCapacity_returnsInvalid() { - var stop1 = createStop(0, +3); // 3 passengers, leaving 1 seat - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); - - // This would require 2 additional seats (passenger + existing 3 = 5) - // But we can only test for 1 additional passenger, so let's test the boundary - var context = new ValidationContext(2, 3, OSLO_EAST, OSLO_WEST, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); // Should have exactly 1 seat available - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java deleted file mode 100644 index 1caff967ded..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/CompositeValidatorTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import java.util.List; -import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationContext; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationResult; - -class CompositeValidatorTest { - - @Test - void validate_allValidatorsPass_returnsValid() { - var validator1 = mock(InsertionValidator.class); - var validator2 = mock(InsertionValidator.class); - - when(validator1.validate(any())).thenReturn(ValidationResult.valid()); - when(validator2.validate(any())).thenReturn(ValidationResult.valid()); - - var composite = new CompositeValidator(List.of(validator1, validator2)); - var context = createDummyContext(); - - var result = composite.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_oneValidatorFails_returnsInvalid() { - var validator1 = mock(InsertionValidator.class); - var validator2 = mock(InsertionValidator.class); - - when(validator1.validate(any())).thenReturn(ValidationResult.valid()); - when(validator2.validate(any())).thenReturn(ValidationResult.invalid("Test failure")); - - var composite = new CompositeValidator(List.of(validator1, validator2)); - var context = createDummyContext(); - - var result = composite.validate(context); - assertFalse(result.isValid()); - assertEquals("Test failure", result.reason()); - } - - @Test - void validate_shortCircuits_afterFirstFailure() { - var validator1 = mock(InsertionValidator.class); - var validator2 = mock(InsertionValidator.class); - var validator3 = mock(InsertionValidator.class); - - when(validator1.validate(any())).thenReturn(ValidationResult.valid()); - when(validator2.validate(any())).thenReturn(ValidationResult.invalid("Fail")); - - var composite = new CompositeValidator(List.of(validator1, validator2, validator3)); - var context = createDummyContext(); - - composite.validate(context); - - verify(validator1).validate(any()); - verify(validator2).validate(any()); - verify(validator3, never()).validate(any()); // Should not be called - } - - @Test - void validate_firstValidatorFails_doesNotCallOthers() { - var validator1 = mock(InsertionValidator.class); - var validator2 = mock(InsertionValidator.class); - - when(validator1.validate(any())).thenReturn(ValidationResult.invalid("First fail")); - - var composite = new CompositeValidator(List.of(validator1, validator2)); - var context = createDummyContext(); - - composite.validate(context); - - verify(validator1).validate(any()); - verify(validator2, never()).validate(any()); - } - - @Test - void standard_includesAllStandardValidators() { - var composite = CompositeValidator.standard(); - - // Test with scenario that should fail capacity validation - var trip = createTripWithCapacity(1, OSLO_CENTER, List.of(createStop(0, +1)), OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var context = new ValidationContext( - 2, - 3, - OSLO_EAST, - OSLO_WEST, - List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH), - timeline - ); - - var result = composite.validate(context); - assertFalse(result.isValid()); - } - - @Test - void standard_checksDirectionalConstraints() { - var composite = CompositeValidator.standard(); - - // Test with scenario that should fail directional validation - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - var context = new ValidationContext( - 1, - 2, - OSLO_SOUTH, // Backtracking - OSLO_NORTH, - List.of(OSLO_CENTER, OSLO_NORTH), - timeline - ); - - var result = composite.validate(context); - assertFalse(result.isValid()); - } - - @Test - void emptyValidator_acceptsAll() { - var composite = new CompositeValidator(List.of()); - var context = createDummyContext(); - - var result = composite.validate(context); - assertTrue(result.isValid()); - } - - @Test - void singleValidator_behavesCorrectly() { - var validator = mock(InsertionValidator.class); - when(validator.validate(any())).thenReturn(ValidationResult.valid()); - - var composite = new CompositeValidator(List.of(validator)); - var context = createDummyContext(); - - var result = composite.validate(context); - assertTrue(result.isValid()); - verify(validator).validate(context); - } - - private ValidationContext createDummyContext() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - return new ValidationContext( - 1, - 2, - OSLO_EAST, - OSLO_WEST, - List.of(OSLO_CENTER, OSLO_NORTH), - timeline - ); - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java deleted file mode 100644 index 6de219a495e..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/validation/DirectionalValidatorTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package org.opentripplanner.ext.carpooling.validation; - -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; - -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.carpooling.util.PassengerCountTimeline; -import org.opentripplanner.ext.carpooling.validation.InsertionValidator.ValidationContext; -import org.opentripplanner.framework.geometry.WgsCoordinate; - -class DirectionalValidatorTest { - - private DirectionalValidator validator; - - @BeforeEach - void setup() { - validator = new DirectionalValidator(); - } - - @Test - void validate_forwardProgress_returnsValid() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Insert along the route direction - var pickup = new WgsCoordinate(59.9239, 10.7522); // Between center and north - var dropoff = new WgsCoordinate(59.9339, 10.7522); - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_pickupCausesBacktracking_returnsInvalid() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Pickup south of starting point (backtracking) - var pickup = OSLO_SOUTH; - var dropoff = OSLO_NORTH; - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - assertFalse(result.isValid()); - assertTrue(result.reason().contains("backtrack") || result.reason().contains("forward")); - } - - @Test - void validate_dropoffCausesBacktracking_allowedWithinTolerance() { - // DirectionalValidator uses 90° tolerance, which allows some backtracking - // This test verifies that moderate backtracking within tolerance is accepted - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Dropoff south of pickup (some backtracking, but within 90° tolerance) - var pickup = new WgsCoordinate(59.9239, 10.7522); - var dropoff = OSLO_CENTER; // Back toward start - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - // With 90° tolerance, moderate backtracking is allowed for routing flexibility - assertTrue(result.isValid()); - } - - @Test - void validate_moderateDetour_allowed() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Slight eastward detour, but still generally northward - var pickup = new WgsCoordinate(59.9239, 10.7622); // North-east - var dropoff = new WgsCoordinate(59.9339, 10.7622); - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); // Should allow reasonable detours - } - - @Test - void validate_pickupAtBeginning_checksFromBoarding() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_MIDPOINT_NORTH, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Insert at very beginning - var pickup = new WgsCoordinate(59.9189, 10.7522); // Just north of center - var dropoff = OSLO_MIDPOINT_NORTH; - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_dropoffAtEnd_checkesToAlighting() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_MIDPOINT_NORTH, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Insert with dropoff near end - var pickup = OSLO_MIDPOINT_NORTH; - var dropoff = new WgsCoordinate(59.9389, 10.7522); // Just south of north - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_multiStopRoute_checksCorrectSegments() { - var stop1 = createStopAt(0, OSLO_EAST); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_EAST, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Insert between first and second segment - var pickup = new WgsCoordinate(59.9189, 10.7722); // Between center and east - var dropoff = new WgsCoordinate(59.9289, 10.7722); - - var context = new ValidationContext(2, 3, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - assertTrue(result.isValid()); - } - - @Test - void validate_largePerpendicularDetour_allowedWithinTolerance() { - // DirectionalValidator uses 90° tolerance to allow perpendicular detours - // This is intentional to provide routing flexibility - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var routeCoords = List.of(OSLO_CENTER, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Perpendicular detour (going east when route goes north = 90°) - var pickup = new WgsCoordinate(59.9239, 10.7522); - var dropoff = new WgsCoordinate(59.9239, 10.8522); // Far east - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = validator.validate(context); - // 90° is exactly at the tolerance boundary and is allowed - assertTrue(result.isValid()); - } - - @Test - void validate_beyondToleranceDetour_returnsInvalid() { - // Test that detours beyond the configured tolerance are rejected - // Use a stricter validator to test this behavior - var strictValidator = new DirectionalValidator(45.0); // 45° tolerance - - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - // Use 3 points so dropoff validation can occur between points - var routeCoords = List.of(OSLO_CENTER, OSLO_MIDPOINT_NORTH, OSLO_NORTH); - var timeline = PassengerCountTimeline.build(trip); - - // Pickup going north-northeast (~45°) - should pass - var pickup = new WgsCoordinate(59.9189, 10.7622); - // Dropoff going east (90° from north) - should exceed 45° tolerance - var dropoff = new WgsCoordinate(59.9239, 10.8522); - - var context = new ValidationContext(1, 2, pickup, dropoff, routeCoords, timeline); - - var result = strictValidator.validate(context); - // 90° deviation should be rejected with 45° tolerance - assertFalse(result.isValid()); - } -} From 78a92675d5f4877e0945a2a3285d46cb79e7fd74 Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 21 Oct 2025 09:47:29 +0200 Subject: [PATCH 10/40] Small cleanups. --- .../ext/carpooling/CarpoolingService.java | 3 - .../routing/CarpoolStreetRouter.java | 160 ++++++++++++++++++ .../service/DefaultCarpoolingService.java | 132 ++------------- .../configure/ConstructApplicationModule.java | 1 + .../server/DefaultServerRequestContext.java | 1 + .../routing/CarpoolStreetRouterTest.java | 116 +++++++++++++ .../transit/speed_test/SpeedTest.java | 23 +-- 7 files changed, 306 insertions(+), 130 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index 7fafe3c850c..4c02013c118 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -11,9 +11,6 @@ * Carpooling enables passengers to join existing driver journeys by being picked up and dropped off * along the driver's route. The service finds optimal insertion points for new passengers while * respecting capacity constraints, time windows, and route deviation budgets. - * - * @see CarpoolingRepository for managing driver trip data - * @see org.opentripplanner.ext.carpooling.routing.OptimalInsertionStrategy for insertion algorithm details */ public interface CarpoolingService { /** diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java new file mode 100644 index 00000000000..70dfd2af382 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java @@ -0,0 +1,160 @@ +package org.opentripplanner.ext.carpooling.routing; + +import java.util.List; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; +import org.opentripplanner.astar.strategy.PathComparator; +import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.request.StreetRequest; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.VertexLinker; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.StreetSearchBuilder; +import org.opentripplanner.street.search.TemporaryVerticesContainer; +import org.opentripplanner.street.search.state.State; +import org.opentripplanner.street.search.strategy.DominanceFunctions; +import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; +import org.opentripplanner.street.service.StreetLimitationParametersService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Street routing service for carpooling insertion evaluation. + *

+ * This router encapsulates all dependencies needed for A* street routing between + * coordinate pairs during carpool insertion optimization. It handles vertex linking, + * search configuration, and path selection for CAR mode routing. + * + *

Routing Strategy

+ *
    + *
  • Mode: CAR mode for both origin and destination
  • + *
  • Algorithm: A* with Euclidean heuristic
  • + *
  • Dominance: Minimum weight
  • + *
  • Vertex Linking: Creates temporary vertices at coordinate locations
  • + *
  • Error Handling: Returns null on routing failure (logged as warning)
  • + *
+ * + *

Design Rationale

+ *

+ * This class follows OTP's established pattern for routing services (see {@code AccessEgressRouter}, + * {@code GraphPathFinder}). Previously, routing logic was created as a lambda in + * {@code DefaultCarpoolingService} and passed to {@code InsertionEvaluator}, creating tight + * coupling. This service class provides proper encapsulation, clear ownership, and improved + * testability. + * + * @see InsertionEvaluator for usage in insertion evaluation + * @see org.opentripplanner.routing.algorithm.raptoradapter.router.street.AccessEgressRouter + * @see org.opentripplanner.routing.impl.GraphPathFinder + */ +public class CarpoolStreetRouter { + + private static final Logger LOG = LoggerFactory.getLogger(CarpoolStreetRouter.class); + + private final Graph graph; + private final VertexLinker vertexLinker; + private final StreetLimitationParametersService streetLimitationParametersService; + private final RouteRequest request; + + /** + * Creates a new carpool street router. + * + * @param graph the street network graph + * @param vertexLinker links coordinates to graph vertices + * @param streetLimitationParametersService provides street routing parameters (speed limits, etc.) + * @param request the route request containing preferences and timing + */ + public CarpoolStreetRouter( + Graph graph, + VertexLinker vertexLinker, + StreetLimitationParametersService streetLimitationParametersService, + RouteRequest request + ) { + this.graph = graph; + this.vertexLinker = vertexLinker; + this.streetLimitationParametersService = streetLimitationParametersService; + this.request = request; + } + + /** + * Routes from one location to another using A* street search. + *

+ * Creates temporary vertices at the given coordinates, performs A* search, + * and returns the best path found. Returns null if routing fails. + * + * @param from origin coordinate + * @param to destination coordinate + * @return the best path found, or null if routing failed + */ + public GraphPath route(GenericLocation from, GenericLocation to) { + try { + var tempVertices = new TemporaryVerticesContainer( + graph, + vertexLinker, + null, + from, + to, + StreetMode.CAR, + StreetMode.CAR + ); + + return carpoolRouting( + new StreetRequest(StreetMode.CAR), + tempVertices.getFromVertices(), + tempVertices.getToVertices(), + streetLimitationParametersService.getMaxCarSpeed() + ); + } catch (Exception e) { + LOG.warn("Routing failed from {} to {}: {}", from, to, e.getMessage()); + return null; + } + } + + /** + * Core A* routing for carpooling optimized for car travel. + *

+ * Configures and executes an A* street search with settings optimized for carpooling: + *

    + *
  • Heuristic: Euclidean distance with max car speed
  • + *
  • Skip Strategy: Duration-based edge skipping
  • + *
  • Dominance: Minimum weight
  • + *
  • Sorting: Results sorted by arrival/departure time
  • + *
+ * + * @param streetRequest the street request specifying CAR mode + * @param fromVertices set of origin vertices + * @param toVertices set of destination vertices + * @param maxCarSpeed maximum car speed in m/s + * @return the first (best) path found, or null if no paths exist + */ + private GraphPath carpoolRouting( + StreetRequest streetRequest, + java.util.Set fromVertices, + java.util.Set toVertices, + float maxCarSpeed + ) { + var preferences = request.preferences().street(); + + var streetSearch = StreetSearchBuilder.of() + .withHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) + .withSkipEdgeStrategy( + new DurationSkipEdgeStrategy(preferences.maxDirectDuration().valueOf(streetRequest.mode())) + ) + .withDominanceFunction(new DominanceFunctions.MinimumWeight()) + .withRequest(request) + .withStreetRequest(streetRequest) + .withFrom(fromVertices) + .withTo(toVertices); + + List> paths = streetSearch.getPathsToTarget(); + paths.sort(new PathComparator(request.arriveBy())); + + if (paths.isEmpty()) { + return null; + } + + return paths.getFirst(); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index a1661265da4..8ba0b35d565 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -4,14 +4,12 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; -import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; import org.opentripplanner.ext.carpooling.filter.FilterChain; import org.opentripplanner.ext.carpooling.internal.CarpoolItineraryMapper; +import org.opentripplanner.ext.carpooling.routing.CarpoolStreetRouter; import org.opentripplanner.ext.carpooling.routing.InsertionCandidate; import org.opentripplanner.ext.carpooling.routing.InsertionEvaluator; import org.opentripplanner.ext.carpooling.routing.InsertionPosition; @@ -20,21 +18,12 @@ import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.routing.api.request.RouteRequest; -import org.opentripplanner.routing.api.request.StreetMode; -import org.opentripplanner.routing.api.request.request.StreetRequest; import org.opentripplanner.routing.api.response.InputField; import org.opentripplanner.routing.api.response.RoutingError; import org.opentripplanner.routing.api.response.RoutingErrorCode; import org.opentripplanner.routing.error.RoutingValidationException; import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.routing.linking.VertexLinker; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.StreetSearchBuilder; -import org.opentripplanner.street.search.TemporaryVerticesContainer; -import org.opentripplanner.street.search.state.State; -import org.opentripplanner.street.search.strategy.DominanceFunctions; -import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; import org.opentripplanner.street.service.StreetLimitationParametersService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,6 +78,8 @@ public class DefaultCarpoolingService implements CarpoolingService { private final StreetLimitationParametersService streetLimitationParametersService; private final FilterChain preFilters; private final CarpoolItineraryMapper itineraryMapper; + private final PassengerDelayConstraints delayConstraints; + private final InsertionPositionFinder positionFinder; /** * Creates a new carpooling service with the specified dependencies. @@ -115,6 +106,8 @@ public DefaultCarpoolingService( this.streetLimitationParametersService = streetLimitationParametersService; this.preFilters = FilterChain.standard(); this.itineraryMapper = new CarpoolItineraryMapper(); + this.delayConstraints = new PassengerDelayConstraints(); + this.positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator()); } @Override @@ -124,6 +117,9 @@ public List route(RouteRequest request) throws RoutingValidationExcep WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate()); WgsCoordinate passengerDropoff = new WgsCoordinate(request.to().getCoordinate()); var passengerDepartureTime = request.dateTime(); + var searchWindow = request.searchWindow() == null + ? Duration.ofMinutes(30) + : request.searchWindow(); LOG.debug( "Finding carpool itineraries from {} to {} at {}", @@ -143,7 +139,7 @@ public List route(RouteRequest request) throws RoutingValidationExcep passengerPickup, passengerDropoff, passengerDepartureTime, - request.searchWindow() == null ? Duration.ofMinutes(30) : request.searchWindow() + searchWindow ) ) .toList(); @@ -158,14 +154,13 @@ public List route(RouteRequest request) throws RoutingValidationExcep return List.of(); } - var routingFunction = createRoutingFunction(request); - - // Phase 1: Find viable positions using fast heuristics (no routing) - var delayConstraints = new PassengerDelayConstraints(); - var positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator()); - - // Phase 2: Evaluate positions with expensive A* routing - var insertionEvaluator = new InsertionEvaluator(routingFunction, delayConstraints); + var router = new CarpoolStreetRouter( + graph, + vertexLinker, + streetLimitationParametersService, + request + ); + var insertionEvaluator = new InsertionEvaluator(router::route, delayConstraints); // Find optimal insertions for remaining trips var insertionCandidates = candidateTrips @@ -232,99 +227,4 @@ private void validateRequest(RouteRequest request) throws RoutingValidationExcep ); } } - - /** - * Creates a routing function that performs A* street routing between coordinate pairs. - *

- * The returned function encapsulates all dependencies needed for routing (graph, vertex linker, - * street parameters) so that {@link InsertionEvaluator} can perform routing without - * knowing about OTP's internal routing infrastructure. This abstraction allows the evaluator - * to remain focused on optimization logic rather than routing mechanics. - * - *

Routing Strategy

- *
    - *
  • Mode: CAR mode for both origin and destination
  • - *
  • Algorithm: A* with Euclidean heuristic
  • - *
  • Vertex Linking: Creates temporary vertices at coordinate locations
  • - *
  • Error Handling: Returns null on routing failure (logged as warning)
  • - *
- * - * @param request the route request containing preferences and parameters for routing - * @return a routing function that performs A* routing between two coordinates, returning - * null if routing fails for any reason (network unreachable, timeout, etc.) - */ - private InsertionEvaluator.RoutingFunction createRoutingFunction(RouteRequest request) { - return (from, to) -> { - try { - var tempVertices = new TemporaryVerticesContainer( - graph, - vertexLinker, - null, - from, - to, - StreetMode.CAR, - StreetMode.CAR - ); - - return carpoolRouting( - request, - new StreetRequest(StreetMode.CAR), - tempVertices.getFromVertices(), - tempVertices.getToVertices(), - streetLimitationParametersService.getMaxCarSpeed() - ); - } catch (Exception e) { - LOG.warn("Routing failed from {} to {}: {}", from, to, e.getMessage()); - return null; - } - }; - } - - /** - * Core A* routing for carpooling optimized for car travel. - *

- * Configures and executes an A* street search with settings optimized for carpooling: - *

    - *
  • Heuristic: Euclidean distance with max car speed for admissibility
  • - *
  • Skip Strategy: Skips edges exceeding max direct duration limit
  • - *
  • Dominance: Minimum weight dominance (finds shortest path)
  • - *
  • Sorting: Results sorted by arrival time or departure time
  • - *
- * - * @param routeRequest the route request containing preferences and parameters - * @param streetRequest the street request specifying CAR mode - * @param fromVertices set of origin vertices to start routing from - * @param toVertices set of destination vertices to route to - * @param maxCarSpeed maximum car speed in meters/second, used for heuristic calculation - * @return the first (best) path found, or null if no paths exist - */ - private GraphPath carpoolRouting( - RouteRequest routeRequest, - StreetRequest streetRequest, - java.util.Set fromVertices, - java.util.Set toVertices, - float maxCarSpeed - ) { - var preferences = routeRequest.preferences().street(); - - var streetSearch = StreetSearchBuilder.of() - .withHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) - .withSkipEdgeStrategy( - new DurationSkipEdgeStrategy(preferences.maxDirectDuration().valueOf(streetRequest.mode())) - ) - .withDominanceFunction(new DominanceFunctions.MinimumWeight()) - .withRequest(routeRequest) - .withStreetRequest(streetRequest) - .withFrom(fromVertices) - .withTo(toVertices); - - List> paths = streetSearch.getPathsToTarget(); - paths.sort(new PathComparator(routeRequest.arriveBy())); - - if (paths.isEmpty()) { - return null; - } - - return paths.getFirst(); - } } diff --git a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java index 216cc50e007..94a7912d5b7 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java @@ -11,6 +11,7 @@ import org.opentripplanner.apis.transmodel.configure.TransmodelSchema; import org.opentripplanner.astar.spi.TraverseVisitor; import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService; import org.opentripplanner.ext.geocoder.LuceneIndex; import org.opentripplanner.ext.interactivelauncher.api.LauncherRequestDecorator; import org.opentripplanner.ext.ridehailing.RideHailingService; diff --git a/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java b/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java index b35d967b70d..81396ec3fdc 100644 --- a/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java +++ b/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java @@ -10,6 +10,7 @@ import org.opentripplanner.apis.transmodel.configure.TransmodelSchema; import org.opentripplanner.astar.spi.TraverseVisitor; import org.opentripplanner.ext.carpooling.CarpoolingService; +import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService; import org.opentripplanner.ext.flex.FlexParameters; import org.opentripplanner.ext.geocoder.LuceneIndex; import org.opentripplanner.ext.ridehailing.RideHailingService; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java new file mode 100644 index 00000000000..669b909f423 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java @@ -0,0 +1,116 @@ +package org.opentripplanner.ext.carpooling.routing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.api.request.preference.RoutingPreferences; +import org.opentripplanner.routing.api.request.preference.StreetPreferences; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.VertexLinker; +import org.opentripplanner.street.service.StreetLimitationParametersService; + +/** + * Unit tests for {@link CarpoolStreetRouter}. + *

+ * These tests verify the router in isolation by mocking all dependencies. + */ +class CarpoolStreetRouterTest { + + private Graph mockGraph; + private VertexLinker mockVertexLinker; + private StreetLimitationParametersService mockStreetService; + private RouteRequest mockRequest; + private RoutingPreferences mockPreferences; + private StreetPreferences mockStreetPreferences; + private CarpoolStreetRouter router; + + @BeforeEach + void setup() { + mockGraph = mock(Graph.class); + mockVertexLinker = mock(VertexLinker.class); + mockStreetService = mock(StreetLimitationParametersService.class); + mockRequest = mock(RouteRequest.class); + mockPreferences = mock(RoutingPreferences.class); + mockStreetPreferences = mock(StreetPreferences.class); + + // Setup mock chain for preferences + when(mockRequest.preferences()).thenReturn(mockPreferences); + when(mockPreferences.street()).thenReturn(mockStreetPreferences); + when(mockRequest.arriveBy()).thenReturn(false); + when(mockStreetService.getMaxCarSpeed()).thenReturn(30.0f); + + router = new CarpoolStreetRouter(mockGraph, mockVertexLinker, mockStreetService, mockRequest); + } + + @Test + void constructor_storesDependencies() { + assertNotNull(router); + // Router should be successfully constructed with all dependencies + } + + @Test + void route_withValidLocations_returnsNonNull() { + // This is a basic smoke test - actual routing behavior depends on + // the graph and is tested via integration tests + var from = GenericLocation.fromCoordinate(59.9139, 10.7522); // Oslo center + var to = GenericLocation.fromCoordinate(59.9149, 10.7522); // Oslo north + + // Note: Without a real graph, this will likely return null + // The important thing is that it doesn't throw exceptions + var result = router.route(from, to); + // Result can be null if routing fails (expected with mock graph) + // What matters is no exceptions were thrown + } + + @Test + void route_withNullFrom_handlesGracefully() { + var to = GenericLocation.fromCoordinate(59.9149, 10.7522); + + // Should handle null gracefully (return null, not throw) + var result = router.route(null, to); + + // Result should be null (routing failed) + assertNull(result); + } + + @Test + void route_withNullTo_handlesGracefully() { + var from = GenericLocation.fromCoordinate(59.9139, 10.7522); + + // Should handle null gracefully (return null, not throw) + var result = router.route(from, null); + + // Result should be null (routing failed) + assertNull(result); + } + + @Test + void route_multipleCallsSameLocations_behavesConsistently() { + var from = GenericLocation.fromCoordinate(59.9139, 10.7522); + var to = GenericLocation.fromCoordinate(59.9149, 10.7522); + + var result1 = router.route(from, to); + var result2 = router.route(from, to); + + // Results should be consistent (both null or both non-null) + assertEquals(result1 == null, result2 == null); + } + + @Test + void route_multipleDifferentCalls_routesIndependently() { + var from1 = GenericLocation.fromCoordinate(59.9139, 10.7522); + var to1 = GenericLocation.fromCoordinate(59.9149, 10.7522); + var from2 = GenericLocation.fromCoordinate(59.9159, 10.7522); + var to2 = GenericLocation.fromCoordinate(59.9169, 10.7522); + + // Should be able to route multiple different pairs + var result1 = router.route(from1, to1); + var result2 = router.route(from2, to2); + // Both routes should complete without exceptions + // Results may be null with mock graph, but no exceptions + } +} diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index cd35f4250b7..a5c576062a1 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -128,23 +128,24 @@ public SpeedTest( TestServerContext.createStreetLimitationParametersService(), config.transitRoutingParams, new DefaultTransitService(timetableRepository), - null, - null, + null, // TriasApiParameters + null, // GtfsApiParameters VectorTileConfig.DEFAULT, TestServerContext.createVehicleParkingService(), TestServerContext.createVehicleRentalService(), VertexLinkerTestFactory.of(graph), TestServerContext.createViaTransferResolver(graph, transitService), TestServerContext.createWorldEnvelopeService(), - null, - null, - null, - null, - null, - null, - null, - null, - null + null, // CarpoolingService + null, // ItineraryDecorator + null, // EmpiricalDelayService + null, // LuceneIndex + null, // GraphQLSchema (gtfs) + null, // GraphQLSchema (transmodel) + null, // SorlandsbanenNorwayService + null, // StopConsolidationService + null, // TraverseVisitor + null // TransmodelAPIParameters ); // Creating raptor transit data should be integrated into the TimetableRepository, but for now // we do it manually here From d51b5a800d387601d9fb966d37bf203f2887b117 Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 31 Oct 2025 10:16:23 +0100 Subject: [PATCH 11/40] Fixes a stop assignment preference in calls. --- .../ext/carpooling/updater/CarpoolSiriMapper.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index 8522c7eee33..3127b7eedac 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -363,6 +363,10 @@ private void validateEstimatedCallOrder(List calls) { private AreaStop buildAreaStop(EstimatedCall call, String id) { var stopAssignments = call.getDepartureStopAssignments(); + if (stopAssignments == null || stopAssignments.isEmpty()) { + stopAssignments = call.getArrivalStopAssignments(); + } + if (stopAssignments == null || stopAssignments.size() != 1) { throw new IllegalArgumentException("Expected exactly one stop assignment for call: " + call); } From 321dc2029c9d09db930404de411e2fa14bd46fcf Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 31 Oct 2025 15:54:48 +0100 Subject: [PATCH 12/40] Addresses most comments in PR so far. --- .../configure/CarpoolingModule.java | 5 +- .../PassengerDelayConstraints.java | 47 +--- .../DirectionalCompatibilityFilter.java | 25 +- .../filter/DistanceBasedFilter.java | 18 +- .../ext/carpooling/filter/FilterChain.java | 30 +-- .../carpooling/filter/TimeBasedFilter.java | 7 - .../internal/CarpoolItineraryMapper.java | 12 +- .../ext/carpooling/model/CarpoolTrip.java | 6 +- .../carpooling/model/CarpoolTripBuilder.java | 1 - .../routing/CarpoolStreetRouter.java | 6 +- .../routing/InsertionEvaluator.java | 35 +-- .../routing/InsertionPositionFinder.java | 36 +-- .../updater/SiriETCarpoolingUpdater.java | 3 - .../ext/carpooling/util/BeelineEstimator.java | 26 +- .../ext/carpooling/util/GraphPathUtils.java | 28 +++ .../PassengerDelayConstraintsTest.java | 227 +++++++++++++----- 16 files changed, 274 insertions(+), 238 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/GraphPathUtils.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index ea50dc3d975..b4110cf41de 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -3,6 +3,7 @@ import dagger.Module; import dagger.Provides; import jakarta.inject.Singleton; +import javax.annotation.Nullable; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.ext.carpooling.CarpoolingService; import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository; @@ -17,6 +18,7 @@ public class CarpoolingModule { @Provides @Singleton + @Nullable public CarpoolingRepository provideCarpoolingRepository() { if (OTPFeature.CarPooling.isOff()) { return null; @@ -25,8 +27,9 @@ public CarpoolingRepository provideCarpoolingRepository() { } @Provides + @Nullable public static CarpoolingService provideCarpoolingService( - CarpoolingRepository repository, + @Nullable CarpoolingRepository repository, Graph graph, VertexLinker vertexLinker, StreetLimitationParametersService streetLimitationParametersService diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java index 1b21a5cc177..fe3ffa17b95 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java @@ -1,11 +1,8 @@ package org.opentripplanner.ext.carpooling.constraints; import java.time.Duration; -import java.util.List; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.state.State; +import org.opentripplanner.ext.carpooling.routing.InsertionPosition; +import org.opentripplanner.utils.time.DurationUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,59 +43,39 @@ public PassengerDelayConstraints() { * @param maxDelay Maximum acceptable delay for existing passengers */ public PassengerDelayConstraints(Duration maxDelay) { - if (maxDelay.isNegative()) { - throw new IllegalArgumentException("maxDelay must be non-negative"); - } - this.maxDelay = maxDelay; + this.maxDelay = DurationUtils.requireNonNegative(maxDelay); } /** * Checks if a passenger insertion satisfies delay constraints. * - * @param originalCumulativeTimes Cumulative duration to each point in original route - * @param modifiedSegments Route segments after passenger insertion + * @param originalCumulativeDurations Cumulative duration to each point in original route + * @param modifiedCumulativeDurations Cumulative duration to each point in modified route * @param pickupPos Position where passenger pickup is inserted (1-indexed) * @param dropoffPos Position where passenger dropoff is inserted (1-indexed) * @return true if all existing passengers experience acceptable delays */ public boolean satisfiesConstraints( - Duration[] originalCumulativeTimes, - List> modifiedSegments, + Duration[] originalCumulativeDurations, + Duration[] modifiedCumulativeDurations, int pickupPos, int dropoffPos ) { // If no existing stops (only boarding and alighting), no constraint to check - if (originalCumulativeTimes.length <= 2) { + if (originalCumulativeDurations.length <= 2) { return true; } - // Calculate cumulative times for modified route - Duration[] modifiedTimes = new Duration[modifiedSegments.size() + 1]; - modifiedTimes[0] = Duration.ZERO; - for (int i = 0; i < modifiedSegments.size(); i++) { - GraphPath segment = modifiedSegments.get(i); - Duration segmentDuration = Duration.between( - segment.states.getFirst().getTime(), - segment.states.getLast().getTime() - ); - modifiedTimes[i + 1] = modifiedTimes[i].plus(segmentDuration); - } - // Check delay at each existing stop (exclude boarding at 0 and alighting at end) for ( int originalIndex = 1; - originalIndex < originalCumulativeTimes.length - 1; + originalIndex < originalCumulativeDurations.length - 1; originalIndex++ ) { - int modifiedIndex = - org.opentripplanner.ext.carpooling.routing.InsertionPosition.mapOriginalIndex( - originalIndex, - pickupPos, - dropoffPos - ); + int modifiedIndex = InsertionPosition.mapOriginalIndex(originalIndex, pickupPos, dropoffPos); - Duration originalTime = originalCumulativeTimes[originalIndex]; - Duration modifiedTime = modifiedTimes[modifiedIndex]; + Duration originalTime = originalCumulativeDurations[originalIndex]; + Duration modifiedTime = modifiedCumulativeDurations[modifiedIndex]; Duration delay = modifiedTime.minus(originalTime); if (delay.compareTo(maxDelay) > 0) { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java index 5a9929b81a2..a450380215d 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java @@ -66,19 +66,13 @@ public boolean accepts( } } - // Check full route as fallback (handles complex multi-segment compatibility) - if ( - isSegmentCompatible( - routePoints.get(0), - routePoints.get(routePoints.size() - 1), - passengerBearing - ) - ) { + // Check full route as fallback + if (isSegmentCompatible(routePoints.getFirst(), routePoints.getLast(), passengerBearing)) { LOG.debug( "Trip {} accepted: passenger journey aligns with full route ({} to {})", trip.getId(), - routePoints.get(0), - routePoints.get(routePoints.size() - 1) + routePoints.getFirst(), + routePoints.getLast() ); return true; } @@ -91,6 +85,10 @@ public boolean accepts( return false; } + double getBearingToleranceDegrees() { + return bearingToleranceDegrees; + } + /** * Checks if a segment is directionally compatible with the passenger journey. * @@ -110,11 +108,4 @@ private boolean isSegmentCompatible( return bearingDiff <= bearingToleranceDegrees; } - - /** - * Gets the configured bearing tolerance in degrees. - */ - public double getBearingToleranceDegrees() { - return bearingToleranceDegrees; - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java index 3fd01bf2591..d4078387139 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java @@ -20,11 +20,6 @@ public class DistanceBasedFilter implements TripFilter { private static final Logger LOG = LoggerFactory.getLogger(DistanceBasedFilter.class); - /** - * Default maximum distance: 50km. - * If all segments of the trip's route are more than this distance from - * both passenger pickup and dropoff, the trip is rejected. - */ public static final double DEFAULT_MAX_DISTANCE_METERS = 50_000; private final double maxDistanceMeters; @@ -94,6 +89,10 @@ public boolean accepts( return false; } + double getMaxDistanceMeters() { + return maxDistanceMeters; + } + /** * Calculates the distance from a point to a line segment. *

@@ -131,7 +130,6 @@ private double distanceToLineSegment( double dx = lineEnd.longitude() - lineStart.longitude(); double dy = lineEnd.latitude() - lineStart.latitude(); - // Calculate squared length of line segment double lineLengthSquared = dx * dx + dy * dy; // Calculate projection parameter t @@ -152,17 +150,9 @@ private double distanceToLineSegment( double closestLat = lineStart.latitude() + t * dy; WgsCoordinate closestPoint = new WgsCoordinate(closestLat, closestLon); - // Return spherical distance from point to closest point on segment return SphericalDistanceLibrary.fastDistance( point.asJtsCoordinate(), closestPoint.asJtsCoordinate() ); } - - /** - * Gets the configured maximum distance in meters. - */ - public double getMaxDistanceMeters() { - return maxDistanceMeters; - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java index 5cdc9a3b1e9..a782568096c 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java @@ -35,10 +35,10 @@ public FilterChain(List filters) { public static FilterChain standard() { return new FilterChain( List.of( - new CapacityFilter(), // Fastest: O(1) - new TimeBasedFilter(), // Very fast: O(1) - new DistanceBasedFilter(), // Fast: O(1) with 4 distance calculations - new DirectionalCompatibilityFilter() // Medium: O(n) segments + new CapacityFilter(), + new TimeBasedFilter(), + new DistanceBasedFilter(), + new DirectionalCompatibilityFilter() ) ); } @@ -49,12 +49,9 @@ public boolean accepts( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff ) { - for (TripFilter filter : filters) { - if (!filter.accepts(trip, passengerPickup, passengerDropoff)) { - return false; // Short-circuit: filter rejected the trip - } - } - return true; // All filters passed + return filters + .stream() + .allMatch(filter -> filter.accepts(trip, passengerPickup, passengerDropoff)); } @Override @@ -65,19 +62,16 @@ public boolean accepts( Instant passengerDepartureTime, Duration searchWindow ) { - for (TripFilter filter : filters) { - if ( - !filter.accepts( + return filters + .stream() + .allMatch(filter -> + filter.accepts( trip, passengerPickup, passengerDropoff, passengerDepartureTime, searchWindow ) - ) { - return false; // Short-circuit: filter rejected the trip - } - } - return true; // All filters passed + ); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java index b05f325e95c..565ca4b9328 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java @@ -24,11 +24,6 @@ public boolean accepts( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff ) { - // Cannot filter without time information - LOG.warn( - "TimeBasedFilter called without time parameter - accepting all trips. " + - "Use accepts(..., Instant, Duration) instead." - ); return true; } @@ -42,10 +37,8 @@ public boolean accepts( ) { Instant tripStartTime = trip.startTime().toInstant(); - // Calculate time difference Duration timeDiff = Duration.between(tripStartTime, passengerDepartureTime).abs(); - // Check if within time window boolean withinWindow = timeDiff.compareTo(searchWindow) <= 0; if (!withinWindow) { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java index 66e2ec4526a..995118aef50 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.time.ZoneId; import java.util.List; +import javax.annotation.Nullable; import org.opentripplanner.ext.carpooling.model.CarpoolLeg; import org.opentripplanner.ext.carpooling.routing.InsertionCandidate; import org.opentripplanner.framework.geometry.GeometryUtils; @@ -92,14 +93,13 @@ public class CarpoolItineraryMapper { * @return an itinerary with a single carpool leg, or null if shared segments are empty * (should not occur for valid candidates) */ + @Nullable public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) { - // Get shared route segments (passenger pickup to dropoff) var sharedSegments = candidate.getSharedSegments(); if (sharedSegments.isEmpty()) { return null; } - // Calculate times var pickupSegments = candidate.getPickupSegments(); Duration pickupDuration = Duration.ZERO; for (var segment : pickupSegments) { @@ -110,7 +110,6 @@ public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) var driverPickupTime = candidate.trip().startTime().plus(pickupDuration); - // Passenger start time is max of request time and when driver arrives var startTime = request.dateTime().isAfter(driverPickupTime.toInstant()) ? request.dateTime().atZone(ZoneId.of("Europe/Oslo")) : driverPickupTime; @@ -125,17 +124,14 @@ public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) var endTime = startTime.plus(carpoolDuration); - // Get vertices from first and last segment - var firstSegment = sharedSegments.get(0); - var lastSegment = sharedSegments.get(sharedSegments.size() - 1); + var firstSegment = sharedSegments.getFirst(); + var lastSegment = sharedSegments.getLast(); Vertex fromVertex = firstSegment.states.getFirst().getVertex(); Vertex toVertex = lastSegment.states.getLast().getVertex(); - // Collect all edges from shared segments var allEdges = sharedSegments.stream().flatMap(seg -> seg.edges.stream()).toList(); - // Create carpool leg CarpoolLeg carpoolLeg = CarpoolLeg.of() .withStartTime(startTime) .withEndTime(endTime) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 501b242125a..87b1b1bfe8a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -6,6 +6,8 @@ import java.util.Collections; import java.util.List; import javax.annotation.Nullable; +import org.opentripplanner.ext.carpooling.CarpoolingRepository; +import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.framework.LogInfo; @@ -40,7 +42,7 @@ *

* CarpoolTrip instances are immutable. Updates to trip state (e.g., adding a booked passenger) * require creating a new trip instance via {@link CarpoolTripBuilder} and upserting it to the - * {@link org.opentripplanner.ext.carpooling.CarpoolingRepository}. + * {@link CarpoolingRepository}. * *

Usage in Routing

*

@@ -53,7 +55,7 @@ * * @see CarpoolStop for individual stop details * @see CarpoolTripBuilder for constructing trip instances - * @see org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater for trip updates + * @see SiriETCarpoolingUpdater for trip updates */ public class CarpoolTrip extends AbstractTransitEntity diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java index 974d606163e..ab43ccf8202 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java @@ -122,7 +122,6 @@ public List stops() { @Override protected CarpoolTrip buildFromValues() { - // Validate stops are properly ordered by sequence number validateStopSequence(); return new CarpoolTrip(this); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java index 70dfd2af382..6510b254853 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java @@ -5,10 +5,12 @@ import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.routing.algorithm.raptoradapter.router.street.AccessEgressRouter; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; import org.opentripplanner.routing.api.request.request.StreetRequest; import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.impl.GraphPathFinder; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; @@ -46,8 +48,8 @@ * testability. * * @see InsertionEvaluator for usage in insertion evaluation - * @see org.opentripplanner.routing.algorithm.raptoradapter.router.street.AccessEgressRouter - * @see org.opentripplanner.routing.impl.GraphPathFinder + * @see AccessEgressRouter + * @see GraphPathFinder */ public class CarpoolStreetRouter { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java index 9aec11e63d5..1a05b7c3656 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java @@ -1,5 +1,7 @@ package org.opentripplanner.ext.carpooling.routing; +import static org.opentripplanner.ext.carpooling.util.GraphPathUtils.calculateCumulativeDurations; + import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -77,24 +79,6 @@ private GraphPath[] routeBaselineSegments(List[] segments) { - Duration[] cumulativeTimes = new Duration[segments.length + 1]; - cumulativeTimes[0] = Duration.ZERO; - - for (int i = 0; i < segments.length; i++) { - Duration segmentDuration = Duration.between( - segments[i].states.getFirst().getTime(), - segments[i].states.getLast().getTime() - ); - cumulativeTimes[i + 1] = cumulativeTimes[i].plus(segmentDuration); - } - - return cumulativeTimes; - } - /** * Evaluates pre-filtered insertion positions using A* routing. *

@@ -122,11 +106,11 @@ public InsertionCandidate findBestInsertion( return null; } - Duration[] cumulativeTimes = calculateCumulativeTimes(baselineSegments); + Duration[] cumulativeDurations = calculateCumulativeDurations(baselineSegments); InsertionCandidate bestCandidate = null; Duration minAdditionalDuration = Duration.ofDays(1); - Duration baselineDuration = cumulativeTimes[cumulativeTimes.length - 1]; + Duration baselineDuration = cumulativeDurations[cumulativeDurations.length - 1]; for (InsertionPosition position : viablePositions) { InsertionCandidate candidate = evaluateInsertion( @@ -136,7 +120,7 @@ public InsertionCandidate findBestInsertion( passengerPickup, passengerDropoff, baselineSegments, - cumulativeTimes, + cumulativeDurations, baselineDuration ); @@ -176,7 +160,7 @@ private InsertionCandidate evaluateInsertion( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff, GraphPath[] baselineSegments, - Duration[] originalCumulativeTimes, + Duration[] originalCumulativeDurations, Duration baselineDuration ) { // Build modified route segments by reusing cached baseline segments @@ -204,8 +188,10 @@ private InsertionCandidate evaluateInsertion( // Check passenger delay constraints if ( !delayConstraints.satisfiesConstraints( - originalCumulativeTimes, - modifiedSegments, + originalCumulativeDurations, + calculateCumulativeDurations( + modifiedSegments.toArray(new GraphPath[modifiedSegments.size()]) + ), pickupPos, dropoffPos ) @@ -339,7 +325,6 @@ private int getBaselineSegmentIndex( } } - // No matching baseline segment found - this segment must be routed LOG.trace( "Modified segment {} has no matching baseline segment (endpoints changed)", modifiedIndex diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java index e563573d60d..031d2b90d61 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java @@ -22,9 +22,6 @@ *

  • Beeline delay heuristic - optimistic straight-line time estimates
  • * *

    - * By rejecting incompatible positions early, this class significantly reduces the number - * of expensive routing operations needed by {@link OptimalInsertionStrategy}. - *

    * This follows the established OTP pattern of separating candidate generation from evaluation, * similar to {@code TransferGenerator} and {@code StreetNearbyStopFinder}. */ @@ -73,10 +70,8 @@ public List findViablePositions( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff ) { - // Extract route points from trip List routePoints = trip.routePoints(); - // Calculate beeline times internally - this is an implementation detail Duration[] beelineTimes = beelineEstimator.calculateCumulativeTimes(routePoints); List viable = new ArrayList<>(); @@ -92,7 +87,6 @@ public List findViablePositions( continue; } - // Directional validation if ( !insertionMaintainsForwardProgress( routePoints, @@ -110,7 +104,6 @@ public List findViablePositions( continue; } - // Beeline delay check (only if there are existing stops to protect) if (routePoints.size() > 2) { if ( !passesBeelineDelayCheck( @@ -131,7 +124,6 @@ public List findViablePositions( } } - // This position passed all checks! viable.add(new InsertionPosition(pickupPos, dropoffPos)); } } @@ -158,7 +150,6 @@ private boolean insertionMaintainsForwardProgress( WgsCoordinate passengerPickup, WgsCoordinate passengerDropoff ) { - // Validate pickup insertion if (pickupPos > 0 && pickupPos < routePoints.size()) { WgsCoordinate prevPoint = routePoints.get(pickupPos - 1); WgsCoordinate nextPoint = routePoints.get(pickupPos); @@ -168,27 +159,20 @@ private boolean insertionMaintainsForwardProgress( } } - // Validate dropoff insertion (in modified route with pickup already inserted) - int dropoffPosInModified = dropoffPos; - if (dropoffPosInModified > 0 && dropoffPosInModified <= routePoints.size()) { - // Get the previous point (which might be the pickup if dropoff is right after) + if (dropoffPos > 0 && dropoffPos <= routePoints.size()) { WgsCoordinate prevPoint; - if (dropoffPosInModified == pickupPos) { - prevPoint = passengerPickup; // Previous point is the pickup - } else if (dropoffPosInModified - 1 < routePoints.size()) { - prevPoint = routePoints.get(dropoffPosInModified - 1); + if (dropoffPos == pickupPos) { + prevPoint = passengerPickup; + } else if (dropoffPos - 1 < routePoints.size()) { + prevPoint = routePoints.get(dropoffPos - 1); } else { - // Edge case: dropoff at the end return true; } - // Get next point if it exists - if (dropoffPosInModified < routePoints.size()) { - WgsCoordinate nextPoint = routePoints.get(dropoffPosInModified); + if (dropoffPos < routePoints.size()) { + WgsCoordinate nextPoint = routePoints.get(dropoffPos); - if (!maintainsForwardProgress(prevPoint, passengerDropoff, nextPoint)) { - return false; - } + return maintainsForwardProgress(prevPoint, passengerDropoff, nextPoint); } } @@ -270,10 +254,10 @@ private boolean passesBeelineDelayCheck( beelineDelay.getSeconds(), delayConstraints.getMaxDelay().getSeconds() ); - return false; // Reject early! + return false; } } - return true; // Passes beeline check, proceed with A* routing + return true; } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index 7f99587b02d..ac4ba6315ac 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -28,9 +28,6 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { private static final Logger LOG = LoggerFactory.getLogger(SiriETCarpoolingUpdater.class); - /** - * Update streamer - */ private final EstimatedTimetableSource updateSource; private final CarpoolingRepository repository; diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java index 366b982aa24..be5a0fa7005 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java @@ -59,6 +59,14 @@ public BeelineEstimator(double detourFactor, double speedMps) { this.speedMps = speedMps; } + public double getDetourFactor() { + return detourFactor; + } + + public double getSpeedMps() { + return speedMps; + } + /** * Estimates travel duration between two points using beeline distance. * @@ -98,22 +106,4 @@ public Duration[] calculateCumulativeTimes(List points) { return cumulativeTimes; } - - /** - * Gets the configured detour factor. - * - * @return Detour factor - */ - public double getDetourFactor() { - return detourFactor; - } - - /** - * Gets the configured average speed in meters per second. - * - * @return Speed in m/s - */ - public double getSpeedMps() { - return speedMps; - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/GraphPathUtils.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/GraphPathUtils.java new file mode 100644 index 00000000000..6ee6319d5fc --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/GraphPathUtils.java @@ -0,0 +1,28 @@ +package org.opentripplanner.ext.carpooling.util; + +import java.time.Duration; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +public class GraphPathUtils { + + /** + * Calculates cumulative durations from pre-routed segments. + */ + public static Duration[] calculateCumulativeDurations(GraphPath[] segments) { + Duration[] cumulativeDurations = new Duration[segments.length + 1]; + cumulativeDurations[0] = Duration.ZERO; + + for (int i = 0; i < segments.length; i++) { + Duration segmentDuration = Duration.between( + segments[i].states.getFirst().getTime(), + segments[i].states.getLast().getTime() + ); + cumulativeDurations[i + 1] = cumulativeDurations[i].plus(segmentDuration); + } + + return cumulativeDurations; + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java index 5c19f6fc817..4a2a14f4692 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java @@ -2,9 +2,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.opentripplanner.ext.carpooling.TestFixtures.*; +import static org.opentripplanner.ext.carpooling.util.GraphPathUtils.calculateCumulativeDurations; import java.time.Duration; -import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.opentripplanner.astar.model.GraphPath; @@ -27,14 +27,21 @@ void satisfiesConstraints_noExistingStops_alwaysAccepts() { Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10) }; // Modified route with passenger inserted - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), + }; // Should accept - no existing passengers to protect - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 2)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 2 + ) + ); } @Test @@ -45,14 +52,21 @@ void satisfiesConstraints_delayWellUnderThreshold_accepts() { // Modified route: boarding -> pickup -> stop1 -> dropoff -> alighting // Timings: 0min -> 3min -> 7min -> 12min -> 17min // Stop1 delay: 7min - 5min = 2min (well under 5min threshold) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(4)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -63,14 +77,21 @@ void satisfiesConstraints_delayExactlyAtThreshold_accepts() { // Modified route where stop1 is delayed by exactly 5 minutes // Timings: 0min -> 5min -> 15min -> 20min -> 25min // Stop1 delay: 15min - 10min = 5min (exactly at threshold) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -81,14 +102,21 @@ void satisfiesConstraints_delayOverThreshold_rejects() { // Modified route where stop1 is delayed by 6 minutes (over 5min threshold) // Timings: 0min -> 5min -> 16min -> 21min -> 26min // Stop1 delay: 16min - 10min = 6min (exceeds threshold) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertFalse(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertFalse( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -105,15 +133,22 @@ void satisfiesConstraints_multipleStops_oneOverThreshold_rejects() { // Timings: 0min -> 5min -> 13min -> 18min -> 27min -> 32min // Stop1 delay: 13min - 10min = 3min ✓ // Stop2 delay: 27min - 20min = 7min ✗ - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(9)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertFalse(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertFalse( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -130,15 +165,22 @@ void satisfiesConstraints_multipleStops_allUnderThreshold_accepts() { // Timings: 0min -> 5min -> 12min -> 17min -> 24min -> 34min // Stop1 delay: 12min - 10min = 2min ✓ // Stop2 delay: 24min - 20min = 4min ✓ - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -157,15 +199,22 @@ void satisfiesConstraints_passengerBeforeAllStops_checksAllStops() { // Timings: 0min -> 3min -> 5min -> 13min -> 24min -> 34min // Stop1 delay: 13min - 10min = 3min ✓ // Stop2 delay: 24min - 20min = 4min ✓ - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(2)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 2)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 2 + ) + ); } @Test @@ -185,15 +234,22 @@ void satisfiesConstraints_passengerAfterAllStops_checksAllStops() { // Timings: 0min -> 11min -> 22min -> 27min -> 30min -> 40min // Stop1 delay: 11min - 10min = 1min ✓ // Stop2 delay: 22min - 20min = 2min ✓ - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 3, 4)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 3, + 4 + ) + ); } @Test @@ -212,15 +268,22 @@ void satisfiesConstraints_passengerBetweenStops_checksAllStops() { // Timings: 0min -> 11min -> 14min -> 17min -> 24min -> 34min // Stop1 delay: 11min - 10min = 1min ✓ // Stop2 delay: 24min - 20min = 4min ✓ - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 2, 3)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 2, + 3 + ) + ); } @Test @@ -230,14 +293,21 @@ void customMaxDelay_acceptsWithinCustomThreshold() { Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; // Stop1 delayed by 8 minutes (within 10min custom threshold) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(13)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertTrue(customConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertTrue( + customConstraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -247,14 +317,21 @@ void customMaxDelay_rejectsOverCustomThreshold() { Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; // Stop1 delayed by 3 minutes (over 2min custom threshold) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertFalse(customConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertFalse( + customConstraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -264,14 +341,21 @@ void customMaxDelay_zeroTolerance_rejectsAnyDelay() { Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; // Stop1 delayed by even 1 second - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5).plusSeconds(1)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertFalse(strictConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertFalse( + strictConstraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -281,14 +365,21 @@ void customMaxDelay_veryPermissive_acceptsLargeDelays() { Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10), Duration.ofMinutes(20) }; // Stop1 delayed by 30 minutes (well within 1 hour threshold) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(35)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertTrue(permissiveConstraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertTrue( + permissiveConstraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -318,14 +409,21 @@ void satisfiesConstraints_noDelay_accepts() { // Modified route where stop1 arrives at exactly the same time // (perfect routing somehow) - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(4)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(6)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 1, 3)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 1, + 3 + ) + ); } @Test @@ -344,7 +442,7 @@ void satisfiesConstraints_tripWithManyStops_checksAll() { // Insert passenger between stop2 and stop3 (positions 3, 4) // All stops should have delays <= 5 minutes // Modified indices: 0,1,2,pickup@3,dropoff@4,3,4,5,6 - List> modifiedSegments = List.of( + GraphPath[] modifiedSegments = new GraphPath[] { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), @@ -352,9 +450,16 @@ void satisfiesConstraints_tripWithManyStops_checksAll() { MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(9)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)) - ); + MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + }; - assertTrue(constraints.satisfiesConstraints(originalTimes, modifiedSegments, 3, 4)); + assertTrue( + constraints.satisfiesConstraints( + originalTimes, + calculateCumulativeDurations(modifiedSegments), + 3, + 4 + ) + ); } } From b919a12611de556c9311d0e5b91b5ec2e8554e39 Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 3 Nov 2025 09:53:34 +0100 Subject: [PATCH 13/40] Makes use of the estimatedVehicleJourneyCode for the TripID instead of some janky lineRef parsing --- .../ext/carpooling/updater/CarpoolSiriMapper.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index 3127b7eedac..120a45f7c99 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -76,8 +76,7 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { var boardingCall = calls.getFirst(); var alightingCall = calls.getLast(); - String lineRef = journey.getLineRef().getValue(); - String tripId = lineRef.substring(lineRef.lastIndexOf(':') + 1); + String tripId = journey.getEstimatedVehicleJourneyCode(); AreaStop boardingArea = buildAreaStop(boardingCall, tripId + "_boarding"); AreaStop alightingArea = buildAreaStop(alightingCall, tripId + "_alighting"); From 84b5b105e3b1fab32574ebe13fe97976c33dd02f Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 3 Nov 2025 14:17:37 +0100 Subject: [PATCH 14/40] Addresses more comments in PR and adds defaults for capacity and deviation budget. --- .../ext/carpooling/model/CarpoolTrip.java | 6 ++---- .../ext/carpooling/routing/CarpoolStreetRouter.java | 12 ------------ .../ext/carpooling/updater/CarpoolSiriMapper.java | 11 ++++++++--- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 87b1b1bfe8a..501b242125a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -6,8 +6,6 @@ import java.util.Collections; import java.util.List; import javax.annotation.Nullable; -import org.opentripplanner.ext.carpooling.CarpoolingRepository; -import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.framework.LogInfo; @@ -42,7 +40,7 @@ *

    * CarpoolTrip instances are immutable. Updates to trip state (e.g., adding a booked passenger) * require creating a new trip instance via {@link CarpoolTripBuilder} and upserting it to the - * {@link CarpoolingRepository}. + * {@link org.opentripplanner.ext.carpooling.CarpoolingRepository}. * *

    Usage in Routing

    *

    @@ -55,7 +53,7 @@ * * @see CarpoolStop for individual stop details * @see CarpoolTripBuilder for constructing trip instances - * @see SiriETCarpoolingUpdater for trip updates + * @see org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdater for trip updates */ public class CarpoolTrip extends AbstractTransitEntity diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java index 6510b254853..23b64a7c2e5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java @@ -5,12 +5,10 @@ import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.model.GenericLocation; -import org.opentripplanner.routing.algorithm.raptoradapter.router.street.AccessEgressRouter; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; import org.opentripplanner.routing.api.request.request.StreetRequest; import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.routing.impl.GraphPathFinder; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; @@ -39,17 +37,7 @@ *

  • Error Handling: Returns null on routing failure (logged as warning)
  • * * - *

    Design Rationale

    - *

    - * This class follows OTP's established pattern for routing services (see {@code AccessEgressRouter}, - * {@code GraphPathFinder}). Previously, routing logic was created as a lambda in - * {@code DefaultCarpoolingService} and passed to {@code InsertionEvaluator}, creating tight - * coupling. This service class provides proper encapsulation, clear ownership, and improved - * testability. - * * @see InsertionEvaluator for usage in insertion evaluation - * @see AccessEgressRouter - * @see GraphPathFinder */ public class CarpoolStreetRouter { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index 120a45f7c99..d8ebea07b0d 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -92,10 +92,14 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { var scheduledDuration = Duration.between(startTime, endTime); - // Calculate estimated drive time between stops for deviation budget + // TODO: Find a better way to exchange deviation budget with providers. var estimatedDriveTime = calculateDriveTimeWithRouting(boardingArea, alightingArea); var deviationBudget = scheduledDuration.minus(estimatedDriveTime); + if (deviationBudget.isNegative()) { + // Using 15 minutes as a default for now when the "time left over" method doesn't work. + deviationBudget = Duration.ofMinutes(15); + } String provider = journey.getOperatorRef().getValue(); @@ -106,7 +110,7 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { List stops = new ArrayList<>(); for (int i = 1; i < calls.size() - 1; i++) { EstimatedCall intermediateCall = calls.get(i); - CarpoolStop stop = buildCarpoolStop(intermediateCall, tripId, i - 1); // 0-based sequence + CarpoolStop stop = buildCarpoolStop(intermediateCall, tripId, i - 1); stops.add(stop); } @@ -117,7 +121,8 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { .withEndTime(endTime) .withProvider(provider) .withDeviationBudget(deviationBudget) - .withAvailableSeats(2) // Default value, could be enhanced if data available + // TODO: Make available seats dynamic based on EstimatedVehicleJourney data + .withAvailableSeats(2) .withStops(stops) .build(); } From 8a8990531938bc510f1dde3c733f4079151b1caa Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 3 Nov 2025 15:50:22 +0100 Subject: [PATCH 15/40] Addresses more comments in PR. --- .../ext/carpooling/model/CarpoolLeg.java | 4 +- .../ext/carpooling/model/CarpoolTrip.java | 15 +++--- .../routing/CarpoolStreetRouter.java | 5 +- .../routing/InsertionEvaluator.java | 3 +- .../routing/InsertionPositionFinder.java | 4 +- .../carpooling/updater/CarpoolSiriMapper.java | 48 +++++++------------ .../ext/carpooling/util/BeelineEstimator.java | 20 ++++---- .../apis/gtfs/datafetchers/LegImpl.java | 1 - .../apis/transmodel/model/plan/LegType.java | 1 - .../routing/algorithm/RoutingWorker.java | 6 +-- .../framework/DebugTimingAggregator.java | 17 +++++++ .../opentripplanner/apis/gtfs/schema.graphqls | 2 +- .../model/CarpoolTripCapacityTest.java | 13 +++++ .../routing/InsertionEvaluatorTest.java | 23 +++++---- .../carpooling/util/BeelineEstimatorTest.java | 4 +- 15 files changed, 91 insertions(+), 75 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java index 5521abaf0d0..866cacab099 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java @@ -35,8 +35,8 @@ import org.opentripplanner.utils.tostring.ToStringBuilder; /** - * One leg of a trip -- that is, a temporally continuous piece of the journey that takes place on a - * particular vehicle, which is running on flexible trip, i.e. not using fixed schedule and stops. + * One leg of a carpooling trip -- that is, a temporally continuous piece of the journey that takes + * place on a particular vehicle. */ public class CarpoolLeg implements Leg { diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 501b242125a..e957d2aaa5f 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -155,28 +155,31 @@ public List routePoints() { * Position semantics: * - Position 0: Boarding area (before any stops) → 0 passengers * - Position N: After Nth stop → cumulative passenger delta up to stop N - * - Position beyond all stops: Alighting area → 0 passengers + * - Position stops.size() + 1: Alighting area → 0 passengers * * @param position The position index (0 = boarding, 1 = after first stop, etc.) * @return Number of passengers after this position - * @throws IllegalArgumentException if position is negative + * @throws IllegalArgumentException if position is negative or greater than stops.size() + 1 */ public int getPassengerCountAtPosition(int position) { if (position < 0) { throw new IllegalArgumentException("Position must be non-negative, got: " + position); } - if (position == 0) { - return 0; + if (position > stops.size() + 1) { + throw new IllegalArgumentException( + "Position " + position + " exceeds valid range (0 to " + (stops.size() + 1) + ")" + ); } - if (position > stops.size()) { + // At the start and end of the trip there are no passengers + if (position == 0 || position == stops.size() + 1) { return 0; } // Accumulate passenger deltas up to this position int count = 0; - for (int i = 0; i < position && i < stops.size(); i++) { + for (int i = 0; i < position; i++) { count += stops.get(i).getPassengerDelta(); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java index 23b64a7c2e5..a12ba912c96 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java @@ -1,6 +1,7 @@ package org.opentripplanner.ext.carpooling.routing; import java.util.List; +import java.util.Set; import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; import org.opentripplanner.astar.strategy.PathComparator; @@ -121,8 +122,8 @@ public GraphPath route(GenericLocation from, GenericLocatio */ private GraphPath carpoolRouting( StreetRequest streetRequest, - java.util.Set fromVertices, - java.util.Set toVertices, + Set fromVertices, + Set toVertices, float maxCarSpeed ) { var preferences = request.preferences().street(); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java index 1a05b7c3656..c88cd630317 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java @@ -174,7 +174,8 @@ private InsertionCandidate evaluateInsertion( ); if (modifiedSegments == null) { - return null; // Routing failed for new segments + // Routing failed for new segments + return null; } // Calculate total duration diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java index 031d2b90d61..a244fb6d7c8 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java @@ -76,8 +76,8 @@ public List findViablePositions( List viable = new ArrayList<>(); - for (int pickupPos = 1; pickupPos <= routePoints.size(); pickupPos++) { - for (int dropoffPos = pickupPos + 1; dropoffPos <= routePoints.size() + 1; dropoffPos++) { + for (int pickupPos = 1; pickupPos < routePoints.size(); pickupPos++) { + for (int dropoffPos = pickupPos + 1; dropoffPos <= routePoints.size(); dropoffPos++) { if (!trip.hasCapacityForInsertion(pickupPos, dropoffPos, 1)) { LOG.trace( "Insertion at pickup={}, dropoff={} rejected by capacity check", diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index d8ebea07b0d..1b823870e86 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -7,6 +7,8 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; +import net.opengis.gml._3.LinearRingType; +import net.opengis.gml._3.PolygonType; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LinearRing; @@ -214,7 +216,10 @@ private GraphPath performCarpoolRouting(Set from, S * @return the estimated drive time as a Duration */ private Duration calculateDriveTimeFromDistance(AreaStop boardingArea, AreaStop alightingArea) { - double distanceInMeters = calculateDistance(boardingArea, alightingArea); + double distanceInMeters = SphericalDistanceLibrary.distance( + boardingArea.getCoordinate().asJtsCoordinate(), + alightingArea.getCoordinate().asJtsCoordinate() + ); // Add a buffer factor for traffic, stops, etc (30% additional time for straight-line) double adjustedDistanceInMeters = distanceInMeters * 1.3; @@ -228,24 +233,6 @@ private Duration calculateDriveTimeFromDistance(AreaStop boardingArea, AreaStop return Duration.ofMinutes(timeInMinutes); } - /** - * Calculate the straight-line distance between two area stops using their centroids. - * - * @param boardingArea the boarding area stop - * @param alightingArea the alighting area stop - * @return the distance in meters - */ - private double calculateDistance(AreaStop boardingArea, AreaStop alightingArea) { - var boardingCoord = boardingArea.getCoordinate(); - var alightingCoord = alightingArea.getCoordinate(); - - // Convert WgsCoordinate to JTS Coordinate for SphericalDistanceLibrary - Coordinate from = new Coordinate(boardingCoord.longitude(), boardingCoord.latitude()); - Coordinate to = new Coordinate(alightingCoord.longitude(), alightingCoord.latitude()); - - return SphericalDistanceLibrary.distance(from, to); - } - /** * Build a CarpoolStop from an EstimatedCall, using point geometry instead of area geometry. * Determines the stop type and passenger delta from the call data. @@ -275,9 +262,6 @@ private CarpoolStop buildCarpoolStop(EstimatedCall call, String tripId, int sequ * Determine the carpool stop type from the EstimatedCall data. */ private CarpoolStop.CarpoolStopType determineCarpoolStopType(EstimatedCall call) { - // This is a simplified implementation - adapt based on your SIRI ET data structure - // You might have specific fields indicating whether this is pickup, drop-off, or both - boolean hasArrival = call.getExpectedArrivalTime() != null || call.getAimedArrivalTime() != null; boolean hasDeparture = @@ -290,7 +274,6 @@ private CarpoolStop.CarpoolStopType determineCarpoolStopType(EstimatedCall call) } else if (hasArrival) { return CarpoolStop.CarpoolStopType.DROP_OFF_ONLY; } else { - // Default fallback return CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF; } } @@ -304,11 +287,14 @@ private int calculatePassengerDelta(EstimatedCall call, CarpoolStop.CarpoolStopT // For now, return a default value of 1 passenger pickup/dropoff if (stopType == CarpoolStop.CarpoolStopType.DROP_OFF_ONLY) { - return -1; // Assume 1 passenger drop-off + // Assume 1 passenger drop-off + return -1; } else if (stopType == CarpoolStop.CarpoolStopType.PICKUP_ONLY) { - return 1; // Assume 1 passenger pickup + // Assume 1 passenger pickup + return 1; } else { - return 0; // No net change for both pickup and drop-off + // No net change for both pickup and drop-off + return 0; } } @@ -318,11 +304,11 @@ private int calculatePassengerDelta(EstimatedCall call, CarpoolStop.CarpoolStopT */ private void validateEstimatedCallOrder(List calls) { if (calls.size() < 2) { - return; // No validation needed for fewer than 2 calls + return; } - ZonedDateTime firstTime = calls.getFirst().getAimedDepartureTime(); // Use departure time for first call - ZonedDateTime lastTime = calls.getLast().getAimedArrivalTime(); // Use arrival time for last call + ZonedDateTime firstTime = calls.getFirst().getAimedDepartureTime(); + ZonedDateTime lastTime = calls.getLast().getAimedArrivalTime(); if (firstTime == null || lastTime == null) { LOG.warn("Cannot validate call order - missing timing information in first or last call"); @@ -388,10 +374,10 @@ private AreaStop buildAreaStop(EstimatedCall call, String id) { .build(); } - private Polygon createPolygonFromGml(net.opengis.gml._3.PolygonType gmlPolygon) { + private Polygon createPolygonFromGml(PolygonType gmlPolygon) { var abstractRing = gmlPolygon.getExterior().getAbstractRing().getValue(); - if (!(abstractRing instanceof net.opengis.gml._3.LinearRingType linearRing)) { + if (!(abstractRing instanceof LinearRingType linearRing)) { throw new IllegalArgumentException("Expected LinearRingType for polygon exterior"); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java index be5a0fa7005..3160340b74f 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java @@ -12,7 +12,7 @@ * performing expensive A* street routing. The estimates are intentionally optimistic * (lower bounds) to ensure we never incorrectly reject valid insertions. *

    - * Formula: duration = (beeline_distance × detour_factor) / average_speed + * Formula: duration = (beeline_distance × detour_factor) / average_speed (in m/s) *

    * The detour factor accounts for the fact that street routes are rarely straight lines. * Typical values: 1.2-1.5, with 1.3 being a reasonable default for urban areas. @@ -33,7 +33,7 @@ public class BeelineEstimator { public static final double DEFAULT_SPEED_MPS = 10.0; private final double detourFactor; - private final double speedMps; + private final double speed; /** * Creates estimator with default parameters. @@ -46,25 +46,25 @@ public BeelineEstimator() { * Creates estimator with custom parameters. * * @param detourFactor Factor by which street routes are longer than beeline (typically 1.2-1.5) - * @param speedMps Average travel speed in meters per second + * @param speed Average travel speed in meters per second */ - public BeelineEstimator(double detourFactor, double speedMps) { + public BeelineEstimator(double detourFactor, double speed) { if (detourFactor < 1.0) { throw new IllegalArgumentException("detourFactor must be >= 1.0 (got " + detourFactor + ")"); } - if (speedMps <= 0) { - throw new IllegalArgumentException("speedMps must be positive (got " + speedMps + ")"); + if (speed <= 0) { + throw new IllegalArgumentException("speedMps must be positive (got " + speed + ")"); } this.detourFactor = detourFactor; - this.speedMps = speedMps; + this.speed = speed; } public double getDetourFactor() { return detourFactor; } - public double getSpeedMps() { - return speedMps; + public double getSpeed() { + return speed; } /** @@ -80,7 +80,7 @@ public Duration estimateDuration(WgsCoordinate from, WgsCoordinate to) { to.asJtsCoordinate() ); double routeDistance = beelineDistance * detourFactor; - double seconds = routeDistance / speedMps; + double seconds = routeDistance / speed; return Duration.ofSeconds((long) seconds); } diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java index eeb6175d6f8..9e96bfb30cc 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/LegImpl.java @@ -176,7 +176,6 @@ public DataFetcher mode() { return s.mode().name(); } if (leg instanceof CarpoolLeg cl) { - // CarpoolLeg is a special case, it has no StreetLeg or TransitLeg, but we can still return the mode return cl.mode().name(); } throw new IllegalStateException("Unhandled leg type: " + leg); diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java index ddf866980f7..e937b8b3320 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/LegType.java @@ -516,7 +516,6 @@ private static Object onLeg( return transitLegAccessor.apply(tl); } if (leg instanceof CarpoolLeg cl) { - // CarpoolLeg is a special case, it has no StreetLeg or TransitLeg, but we can still return the mode return cl.mode(); } throw new IllegalStateException("Unhandled leg type: " + leg); diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java index 9f1cab5e8f7..afd8a4c351a 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java @@ -261,15 +261,13 @@ private RoutingResult routeCarpooling() { if (OTPFeature.CarPooling.isOff()) { return RoutingResult.ok(List.of()); } - // TODO CARPOOLING Add carpooling timer - // debugTimingAggregator.startedCarpoolingRouter(); + debugTimingAggregator.startedDirectCarpoolRouter(); try { return RoutingResult.ok(serverContext.carpoolingService().route(request)); } catch (RoutingValidationException e) { return RoutingResult.failed(e.getRoutingErrors()); } finally { - // TODO CARPOOLING Add - //debugTimingAggregator.finishedCarpoolingRouter(); + debugTimingAggregator.finishedDirectCarpoolRouter(); } } 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 7934f4ab1fb..95405b69120 100644 --- a/application/src/main/java/org/opentripplanner/routing/framework/DebugTimingAggregator.java +++ b/application/src/main/java/org/opentripplanner/routing/framework/DebugTimingAggregator.java @@ -30,6 +30,7 @@ public class DebugTimingAggregator { private final Timer directStreetRouterTimer; private final Timer directFlexRouterTimer; + private final Timer directCarpoolRouterTimer; private final Timer accessTimer; private final Timer egressTimer; @@ -53,6 +54,8 @@ public class DebugTimingAggregator { private long directStreetRouterTime; private Timer.Sample startedDirectFlexRouter; private long directFlexRouterTime; + private Timer.Sample startedDirectCarpoolRouter; + private long directCarpoolRouterTime; private Timer.Sample finishedPatternFiltering; private Timer.Sample finishedAccessEgress; private Timer.Sample finishedRaptorSearch; @@ -109,6 +112,7 @@ public DebugTimingAggregator(MeterRegistry registry, Collection rout egressTimer = Timer.builder("routing.egress").tags(tags).register(registry); accessTimer = Timer.builder("routing.access").tags(tags).register(registry); directFlexRouterTimer = Timer.builder("routing.directFlex").tags(tags).register(registry); + directCarpoolRouterTimer = Timer.builder("routing.directCarpool").tags(tags).register(registry); directStreetRouterTimer = Timer.builder("routing.directStreet").tags(tags).register(registry); } @@ -153,6 +157,19 @@ public void finishedDirectFlexRouter() { directFlexRouterTime = startedDirectFlexRouter.stop(directFlexRouterTimer); } + /** Record the time when starting the direct carpool router search. */ + public void startedDirectCarpoolRouter() { + startedDirectCarpoolRouter = Timer.start(clock); + } + + /** Record the time when we finished the direct carpool router search. */ + public void finishedDirectCarpoolRouter() { + if (startedDirectCarpoolRouter == null) { + return; + } + directCarpoolRouterTime = startedDirectCarpoolRouter.stop(directCarpoolRouterTimer); + } + /** Record the time when starting the transit router search. */ public void startedTransitRouting() { startedTransitRouterTime = Timer.start(clock); diff --git a/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls b/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls index 2384ca9cd7f..8d058cca7c2 100644 --- a/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls +++ b/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls @@ -784,7 +784,7 @@ type Leg { Re-fetching fails when the underlying transit data no longer exists. **Note:** when both id and fare products are queried with [Relay](https://relay.dev/), id should be queried using a suitable GraphQL alias such as `legId: id`. Relay does not accept different fare product ids in otherwise identical legs. - + The identifier is valid for a maximum of 2 years, but sometimes it will fail after a few hours. We do not recommend storing IDs for a long time. """ diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java index 676ba3268a9..cd138ec05ca 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java @@ -65,6 +65,19 @@ void getPassengerCountAtPosition_negativePosition_throwsException() { assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(-1)); } + @Test + void getPassengerCountAtPosition_positionTooLarge_throwsException() { + var stop1 = createStop(0, +1); + var stop2 = createStop(1, +1); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + + // Valid positions are 0 to 3 (stops.size() + 1) + // Position 4 should throw + assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(4)); + // Position 999 should also throw + assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(999)); + } + @Test void hasCapacityForInsertion_noPassengers_hasCapacity() { var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index f0dc61fe1da..130a7e7c0ed 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -80,23 +80,23 @@ void findOptimalInsertion_oneValidPosition_returnsCandidate() { @Test void findOptimalInsertion_routingFails_skipsPosition() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + // Use a trip with one stop to have multiple viable insertion positions + var stop1 = createStopAt(0, OSLO_EAST); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); - // Create mock paths BEFORE any when() statements var mockPath = createMockGraphPath(Duration.ofMinutes(5)); // Routing sequence: - // 1. Baseline calculation (1 segment: OSLO_CENTER → OSLO_NORTH) = mockPath - // 2. First insertion attempt fails (null, null, null for 3 segments) - // 3. Second insertion attempt succeeds (mockPath for all 3 segments) + // 1. Baseline calculation (2 segments: OSLO_CENTER → OSLO_EAST → OSLO_NORTH) = mockPath x2 + // 2. First insertion attempt fails (null for first segment) + // 3. Second insertion attempt succeeds (mockPath for all segments) when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath) // Baseline - .thenReturn(null) // First insertion - segment 1 fails - .thenReturn(mockPath) // Second insertion - all segments succeed - .thenReturn(mockPath) - .thenReturn(mockPath); + .thenReturn(mockPath, mockPath) // Baseline (2 segments) + .thenReturn(null) // First insertion - segment fails + .thenReturn(mockPath, mockPath, mockPath, mockPath); // Second insertion succeeds - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + // Use passenger coordinates that are compatible with trip direction (CENTER->EAST->NORTH) + var result = findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST); // Should skip failed routing and find a valid one assertNotNull(result); @@ -127,7 +127,6 @@ void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { var stop2 = createStopAt(1, OSLO_WEST); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - // Create mock paths BEFORE any when() statements var mockPath = createMockGraphPath(); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java index e887865f41c..d3ef1a3fd72 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java @@ -175,10 +175,10 @@ void getDetourFactor_returnsConfiguredValue() { @Test void getSpeedMps_returnsConfiguredValue() { - assertEquals(10.0, estimator.getSpeedMps()); + assertEquals(10.0, estimator.getSpeed()); var customEstimator = new BeelineEstimator(1.3, 15.0); - assertEquals(15.0, customEstimator.getSpeedMps()); + assertEquals(15.0, customEstimator.getSpeed()); } @Test From d41b37c503d7215bbc1dd85fc142d5180c7283e2 Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 4 Nov 2025 12:39:01 +0100 Subject: [PATCH 16/40] Cleans up the SiriETCarpoolingUpdater. --- .../updater/SiriETCarpoolingUpdater.java | 128 ++++++++++-------- 1 file changed, 70 insertions(+), 58 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index ac4ba6315ac..3b1e837408c 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -1,25 +1,23 @@ package org.opentripplanner.ext.carpooling.updater; import java.util.List; -import java.util.function.Consumer; import org.opentripplanner.ext.carpooling.CarpoolingRepository; import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.updater.spi.PollingGraphUpdater; import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; -import org.opentripplanner.updater.spi.UpdateResult; import org.opentripplanner.updater.support.siri.SiriFileLoader; import org.opentripplanner.updater.support.siri.SiriHttpLoader; import org.opentripplanner.updater.support.siri.SiriLoader; import org.opentripplanner.updater.trip.UrlUpdaterParameters; -import org.opentripplanner.updater.trip.metrics.TripUpdateMetrics; import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; import org.opentripplanner.utils.tostring.ToStringBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import uk.org.siri.siri21.EstimatedTimetableDeliveryStructure; +import uk.org.siri.siri21.EstimatedVehicleJourney; import uk.org.siri.siri21.ServiceDelivery; /** @@ -31,16 +29,8 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { private final EstimatedTimetableSource updateSource; private final CarpoolingRepository repository; - private final CarpoolSiriMapper mapper; - /** - * Feed id that is used for the trip ids in the TripUpdates - */ - private final String feedId; - - private final Consumer metricsConsumer; - public SiriETCarpoolingUpdater( SiriETCarpoolingUpdaterParameters config, CarpoolingRepository repository, @@ -49,61 +39,91 @@ public SiriETCarpoolingUpdater( StreetLimitationParametersService streetLimitationParametersService ) { super(config); - this.feedId = config.feedId(); - - SiriLoader siriHttpLoader = siriLoader(config); - updateSource = new SiriETHttpTripUpdateSource(config.sourceParameters(), siriHttpLoader); + this.updateSource = new SiriETHttpTripUpdateSource(config.sourceParameters(), siriLoader(config)); this.repository = repository; - this.blockReadinessUntilInitialized = config.blockReadinessUntilInitialized(); LOG.info("Creating SIRI-ET updater running every {}: {}", pollingPeriod(), updateSource); - this.metricsConsumer = TripUpdateMetrics.streaming(config); - this.mapper = new CarpoolSiriMapper(graph, vertexLinker, streetLimitationParametersService); } + public interface Parameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { + String url(); + boolean blockReadinessUntilInitialized(); + boolean fuzzyTripMatching(); + } + /** - * Repeatedly makes blocking calls to an UpdateStreamer to retrieve new stop time updates, and - * applies those updates to the graph. + * Repeatedly makes blocking calls to an UpdateStreamer to retrieve carpooling trip updates. */ @Override public void runPolling() { - boolean moreData = false; + boolean moreData; do { - var updates = updateSource.getUpdates(); - if (updates.isPresent()) { - ServiceDelivery serviceDelivery = updates.get().getServiceDelivery(); - moreData = Boolean.TRUE.equals(serviceDelivery.isMoreData()); - List etds = - serviceDelivery.getEstimatedTimetableDeliveries(); - if (etds != null) { - for (EstimatedTimetableDeliveryStructure etd : etds) { - var ejvfs = etd.getEstimatedJourneyVersionFrames(); - for (var ejvf : ejvfs) { - if (ejvf.getEstimatedVehicleJourneies() == null) { - LOG.warn("Received an empty EstimatedJourneyVersionFrame, skipping"); - continue; - } - ejvf - .getEstimatedVehicleJourneies() - .forEach(ejv -> { - try { - var carpoolTrip = mapper.mapSiriToCarpoolTrip(ejv); - if (carpoolTrip != null) { - repository.upsertCarpoolTrip(carpoolTrip); - } - } catch (Exception e) { - LOG.warn("Failed to process EstimatedVehicleJourney: {}", e.getMessage()); - } - }); - } - } + moreData = fetchAndProcessUpdates(); + } while (moreData); + } + + /** + * Fetches updates from the source and processes them. + * + * @return true if there is more data available to fetch + */ + private boolean fetchAndProcessUpdates() { + var updates = updateSource.getUpdates(); + if (updates.isEmpty()) { + return false; + } + + ServiceDelivery serviceDelivery = updates.get().getServiceDelivery(); + processEstimatedTimetableDeliveries(serviceDelivery.getEstimatedTimetableDeliveries()); + return Boolean.TRUE.equals(serviceDelivery.isMoreData()); + } + + /** + * Processes a list of estimated timetable deliveries. + * + * @param deliveries the list of estimated timetable deliveries, may be null + */ + private void processEstimatedTimetableDeliveries( + List deliveries + ) { + if (deliveries == null || deliveries.isEmpty()) { + return; + } + + for (EstimatedTimetableDeliveryStructure delivery : deliveries) { + var frames = delivery.getEstimatedJourneyVersionFrames(); + for (var frame : frames) { + var estimatedVehicleJourneys = frame.getEstimatedVehicleJourneies(); + + if (estimatedVehicleJourneys == null || estimatedVehicleJourneys.isEmpty()) { + LOG.warn("Received an empty EstimatedJourneyVersionFrame, skipping"); + continue; } + + estimatedVehicleJourneys.forEach(this::processEstimatedVehicleJourney); } - } while (moreData); + } + } + + /** + * Processes a single estimated vehicle journey, mapping it to a carpool trip and upserting it + * to the repository. + * + * @param estimatedVehicleJourney the estimated vehicle journey to process + */ + private void processEstimatedVehicleJourney(EstimatedVehicleJourney estimatedVehicleJourney) { + try { + var carpoolTrip = mapper.mapSiriToCarpoolTrip(estimatedVehicleJourney); + if (carpoolTrip != null) { + repository.upsertCarpoolTrip(carpoolTrip); + } + } catch (Exception e) { + LOG.warn("Failed to process EstimatedVehicleJourney: {}", e.getMessage()); + } } @Override @@ -114,14 +134,6 @@ public String toString() { .toString(); } - public interface Parameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { - String url(); - - boolean blockReadinessUntilInitialized(); - - boolean fuzzyTripMatching(); - } - private static SiriLoader siriLoader(SiriETCarpoolingUpdaterParameters config) { // Load real-time updates from a file. if (SiriFileLoader.matchesUrl(config.url())) { From 1fc92b3653ad4373a6c42d910796490d60262611 Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 4 Nov 2025 12:39:27 +0100 Subject: [PATCH 17/40] Cleans up issues in test code. --- ...tures.java => CarpoolTestCoordinates.java} | 18 +++++++------- .../carpooling/TestCarpoolTripBuilder.java | 2 +- .../PassengerDelayConstraintsTest.java | 1 - .../carpooling/filter/CapacityFilterTest.java | 2 +- .../DirectionalCompatibilityFilterTest.java | 2 +- .../filter/DistanceBasedFilterTest.java | 2 +- .../carpooling/filter/FilterChainTest.java | 2 +- .../filter/TimeBasedFilterTest.java | 2 +- .../model/CarpoolTripCapacityTest.java | 2 +- .../routing/InsertionCandidateTest.java | 2 +- .../routing/InsertionEvaluatorTest.java | 2 +- .../routing/InsertionPositionFinderTest.java | 2 +- .../carpooling/util/BeelineEstimatorTest.java | 2 +- .../util/DirectionalCalculatorTest.java | 2 +- .../transit/speed_test/SpeedTest.java | 24 +++++++++---------- 15 files changed, 34 insertions(+), 33 deletions(-) rename application/src/test/java/org/opentripplanner/ext/carpooling/{TestFixtures.java => CarpoolTestCoordinates.java} (78%) diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java b/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolTestCoordinates.java similarity index 78% rename from application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java rename to application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolTestCoordinates.java index 0b57bb05f3f..f2b527f137c 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/TestFixtures.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolTestCoordinates.java @@ -6,14 +6,18 @@ * Shared test coordinates and constants for carpooling tests. * Uses Oslo area coordinates for realistic geographic testing. */ -public class TestFixtures { +public class CarpoolTestCoordinates { // Base coordinates (Oslo area) public static final WgsCoordinate OSLO_CENTER = new WgsCoordinate(59.9139, 10.7522); - public static final WgsCoordinate OSLO_EAST = new WgsCoordinate(59.9149, 10.7922); // ~2.5km east - public static final WgsCoordinate OSLO_NORTH = new WgsCoordinate(59.9439, 10.7522); // ~3.3km north - public static final WgsCoordinate OSLO_SOUTH = new WgsCoordinate(59.8839, 10.7522); // ~3.3km south - public static final WgsCoordinate OSLO_WEST = new WgsCoordinate(59.9139, 10.7122); // ~2.5km west + // ~2.5km east of center + public static final WgsCoordinate OSLO_EAST = new WgsCoordinate(59.9149, 10.7922); + // ~3.3km north of center + public static final WgsCoordinate OSLO_NORTH = new WgsCoordinate(59.9439, 10.7522); + // ~3.3km south of center + public static final WgsCoordinate OSLO_SOUTH = new WgsCoordinate(59.8839, 10.7522); + // ~2.5km west of center + public static final WgsCoordinate OSLO_WEST = new WgsCoordinate(59.9139, 10.7122); // Coordinates for testing routes around obstacles (e.g., lake) public static final WgsCoordinate LAKE_NORTH = new WgsCoordinate(59.9439, 10.7522); @@ -24,11 +28,9 @@ public class TestFixtures { // Intermediate points for testing public static final WgsCoordinate OSLO_MIDPOINT_NORTH = new WgsCoordinate(59.9289, 10.7522); public static final WgsCoordinate OSLO_NORTHEAST = new WgsCoordinate(59.9439, 10.7922); - public static final WgsCoordinate OSLO_SOUTHEAST = new WgsCoordinate(59.8839, 10.7922); - public static final WgsCoordinate OSLO_SOUTHWEST = new WgsCoordinate(59.8839, 10.7122); public static final WgsCoordinate OSLO_NORTHWEST = new WgsCoordinate(59.9439, 10.7122); - private TestFixtures() { + private CarpoolTestCoordinates() { // Utility class } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java index 7e9b622d4db..46b6bd4eaf5 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java @@ -125,7 +125,7 @@ public static CarpoolTrip createTripWithTime( * Creates a CarpoolStop with specified sequence (0-based) and passenger delta. */ public static CarpoolStop createStop(int zeroBasedSequence, int passengerDelta) { - return createStopAt(zeroBasedSequence, passengerDelta, TestFixtures.OSLO_CENTER); + return createStopAt(zeroBasedSequence, passengerDelta, CarpoolTestCoordinates.OSLO_CENTER); } /** diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java index 4a2a14f4692..aa5ddda3e8d 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java @@ -1,7 +1,6 @@ package org.opentripplanner.ext.carpooling.constraints; import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import static org.opentripplanner.ext.carpooling.util.GraphPathUtils.calculateCumulativeDurations; import java.time.Duration; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java index 9f0af91cee0..af7d76077bc 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java @@ -1,8 +1,8 @@ package org.opentripplanner.ext.carpooling.filter; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.util.List; import org.junit.jupiter.api.BeforeEach; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java index 66a82ec3463..199a2806bab 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java @@ -1,8 +1,8 @@ package org.opentripplanner.ext.carpooling.filter; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.util.List; import org.junit.jupiter.api.BeforeEach; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java index c2307107438..36c5ff2eb84 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java @@ -1,8 +1,8 @@ package org.opentripplanner.ext.carpooling.filter; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java index 7f926cca557..eeae017c313 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java @@ -3,8 +3,8 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java index 8a231214917..776ebbe40e4 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java @@ -1,8 +1,8 @@ package org.opentripplanner.ext.carpooling.filter; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.time.Duration; import java.time.ZonedDateTime; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java index cd138ec05ca..6d283977263 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java @@ -1,8 +1,8 @@ package org.opentripplanner.ext.carpooling.model; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java index 9de51944fbb..82645dc4768 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java @@ -1,9 +1,9 @@ package org.opentripplanner.ext.carpooling.routing; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.time.Duration; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index 130a7e7c0ed..8c446525acf 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -3,9 +3,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.time.Duration; import java.util.List; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java index df8c320f873..9a93e26ea6f 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java @@ -1,8 +1,8 @@ package org.opentripplanner.ext.carpooling.routing; import static org.junit.jupiter.api.Assertions.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; import java.time.Duration; import java.util.List; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java index d3ef1a3fd72..c41214603f9 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java @@ -1,7 +1,7 @@ package org.opentripplanner.ext.carpooling.util; import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import java.time.Duration; import java.util.List; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java index 7a89e072918..d44dd7922be 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java @@ -1,7 +1,7 @@ package org.opentripplanner.ext.carpooling.util; import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.TestFixtures.*; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index a5c576062a1..be25646be16 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -128,24 +128,24 @@ public SpeedTest( TestServerContext.createStreetLimitationParametersService(), config.transitRoutingParams, new DefaultTransitService(timetableRepository), - null, // TriasApiParameters - null, // GtfsApiParameters + null, + null, VectorTileConfig.DEFAULT, TestServerContext.createVehicleParkingService(), TestServerContext.createVehicleRentalService(), VertexLinkerTestFactory.of(graph), TestServerContext.createViaTransferResolver(graph, transitService), TestServerContext.createWorldEnvelopeService(), - null, // CarpoolingService - null, // ItineraryDecorator - null, // EmpiricalDelayService - null, // LuceneIndex - null, // GraphQLSchema (gtfs) - null, // GraphQLSchema (transmodel) - null, // SorlandsbanenNorwayService - null, // StopConsolidationService - null, // TraverseVisitor - null // TransmodelAPIParameters + null, + null, + null, + null, + null, + null, + null, + null, + null, + null ); // Creating raptor transit data should be integrated into the TimetableRepository, but for now // we do it manually here From 4a16c98615b188bb74bc10e2a50ff842a0676794 Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 4 Nov 2025 15:12:07 +0100 Subject: [PATCH 18/40] Removes unnecessary Parameters interface from SiriETCarpoolingUpdater. --- .../updater/SiriETCarpoolingUpdater.java | 14 ++++---------- .../updater/SiriETCarpoolingUpdaterParameters.java | 5 ++++- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index 3b1e837408c..ce7e5c2dfab 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -6,11 +6,9 @@ import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.updater.spi.PollingGraphUpdater; -import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; import org.opentripplanner.updater.support.siri.SiriFileLoader; import org.opentripplanner.updater.support.siri.SiriHttpLoader; import org.opentripplanner.updater.support.siri.SiriLoader; -import org.opentripplanner.updater.trip.UrlUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; import org.opentripplanner.utils.tostring.ToStringBuilder; @@ -39,8 +37,10 @@ public SiriETCarpoolingUpdater( StreetLimitationParametersService streetLimitationParametersService ) { super(config); - - this.updateSource = new SiriETHttpTripUpdateSource(config.sourceParameters(), siriLoader(config)); + this.updateSource = new SiriETHttpTripUpdateSource( + config.sourceParameters(), + siriLoader(config) + ); this.repository = repository; this.blockReadinessUntilInitialized = config.blockReadinessUntilInitialized(); @@ -49,12 +49,6 @@ public SiriETCarpoolingUpdater( this.mapper = new CarpoolSiriMapper(graph, vertexLinker, streetLimitationParametersService); } - public interface Parameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { - String url(); - boolean blockReadinessUntilInitialized(); - boolean fuzzyTripMatching(); - } - /** * Repeatedly makes blocking calls to an UpdateStreamer to retrieve carpooling trip updates. */ diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java index ab4530d3866..8cdba1e2cff 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java @@ -2,6 +2,8 @@ import java.time.Duration; import org.opentripplanner.updater.spi.HttpHeaders; +import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; +import org.opentripplanner.updater.trip.UrlUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; public record SiriETCarpoolingUpdaterParameters( @@ -17,7 +19,8 @@ public record SiriETCarpoolingUpdaterParameters( HttpHeaders httpRequestHeaders, boolean producerMetrics ) - implements SiriETCarpoolingUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters { + implements + UrlUpdaterParameters, PollingGraphUpdaterParameters, SiriETHttpTripUpdateSource.Parameters { public SiriETHttpTripUpdateSource.Parameters sourceParameters() { return this; } From 88a4bec615867b550807f7fb07c31da0736014dd Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 4 Nov 2025 16:00:43 +0100 Subject: [PATCH 19/40] Makes use of DirectionUtils instead of creating a new DirectionalCalculator. --- .../ext/carpooling/CarpoolingService.java | 5 +- .../DirectionalCompatibilityFilter.java | 15 +++-- .../routing/InsertionPositionFinder.java | 30 ++++++--- .../util/DirectionalCalculator.java | 59 ----------------- .../framework/geometry/DirectionUtils.java | 23 +++++++ .../routing/InsertionEvaluatorTest.java | 7 +- .../util/DirectionalCalculatorTest.java | 66 ------------------- .../geometry/DirectionUtilsTest.java | 57 ++++++++++++++++ 8 files changed, 115 insertions(+), 147 deletions(-) delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index 4c02013c118..9f4dd19d949 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -3,7 +3,6 @@ import java.util.List; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.routing.api.request.RouteRequest; -import org.opentripplanner.routing.error.RoutingValidationException; /** * Service for finding carpooling options by matching passenger requests with available driver trips. @@ -20,9 +19,7 @@ public interface CarpoolingService { * @param request the routing request containing passenger origin, destination, and preferences * @return list of carpool itineraries, sorted by quality (additional travel time), may be empty * if no compatible trips found. Results are limited to avoid overwhelming users. - * @throws RoutingValidationException if the request is invalid (missing origin/destination, - * invalid coordinates, etc.) * @throws IllegalArgumentException if request is null */ - List route(RouteRequest request) throws RoutingValidationException; + List route(RouteRequest request); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java index a450380215d..257e539e404 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java @@ -2,7 +2,7 @@ import java.util.List; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; +import org.opentripplanner.framework.geometry.DirectionUtils; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,9 +48,9 @@ public boolean accepts( return false; } - double passengerBearing = DirectionalCalculator.calculateBearing( - passengerPickup, - passengerDropoff + double passengerBearing = DirectionUtils.getAzimuth( + passengerPickup.asJtsCoordinate(), + passengerDropoff.asJtsCoordinate() ); for (int i = 0; i < routePoints.size() - 1; i++) { @@ -102,9 +102,12 @@ private boolean isSegmentCompatible( WgsCoordinate segmentEnd, double passengerBearing ) { - double segmentBearing = DirectionalCalculator.calculateBearing(segmentStart, segmentEnd); + double segmentBearing = DirectionUtils.getAzimuth( + segmentStart.asJtsCoordinate(), + segmentEnd.asJtsCoordinate() + ); - double bearingDiff = DirectionalCalculator.bearingDifference(segmentBearing, passengerBearing); + double bearingDiff = DirectionUtils.bearingDifference(segmentBearing, passengerBearing); return bearingDiff <= bearingToleranceDegrees; } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java index a244fb6d7c8..139069a60bf 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java @@ -6,7 +6,7 @@ import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.util.BeelineEstimator; -import org.opentripplanner.ext.carpooling.util.DirectionalCalculator; +import org.opentripplanner.framework.geometry.DirectionUtils; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -187,19 +187,31 @@ private boolean maintainsForwardProgress( WgsCoordinate newPoint, WgsCoordinate next ) { + // Skip check if inserting at an existing point (newPoint equals next or previous) + // This avoids undefined bearing calculations from a point to itself + if (newPoint.equals(next) || newPoint.equals(previous)) { + return true; + } + // Calculate intended direction (previous → next) - double intendedBearing = DirectionalCalculator.calculateBearing(previous, next); + double intendedBearing = DirectionUtils.getAzimuth( + previous.asJtsCoordinate(), + next.asJtsCoordinate() + ); // Calculate detour directions - double bearingToNew = DirectionalCalculator.calculateBearing(previous, newPoint); - double bearingFromNew = DirectionalCalculator.calculateBearing(newPoint, next); + double bearingToNew = DirectionUtils.getAzimuth( + previous.asJtsCoordinate(), + newPoint.asJtsCoordinate() + ); + double bearingFromNew = DirectionUtils.getAzimuth( + newPoint.asJtsCoordinate(), + next.asJtsCoordinate() + ); // Check deviations - double deviationToNew = DirectionalCalculator.bearingDifference(intendedBearing, bearingToNew); - double deviationFromNew = DirectionalCalculator.bearingDifference( - intendedBearing, - bearingFromNew - ); + double deviationToNew = DirectionUtils.bearingDifference(intendedBearing, bearingToNew); + double deviationFromNew = DirectionUtils.bearingDifference(intendedBearing, bearingFromNew); // Allow some deviation but not complete reversal return ( diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java deleted file mode 100644 index 7ca742650e5..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculator.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.opentripplanner.ext.carpooling.util; - -import org.opentripplanner.framework.geometry.WgsCoordinate; - -/** - * Calculates bearings and directional relationships between geographic coordinates. - *

    - * Uses the Haversine formula for accurate bearing calculations on Earth's surface. - * All bearings are in degrees [0, 360) where 0° = North, 90° = East, etc. - */ -public class DirectionalCalculator { - - /** - * Calculates the initial bearing (forward azimuth) from one point to another. - *

    - * Uses the Haversine formula for accurate bearing on Earth's surface. - * - * @param from Starting point - * @param to Ending point - * @return Bearing in degrees [0, 360), where 0° is North - */ - public static double calculateBearing(WgsCoordinate from, WgsCoordinate to) { - double lat1 = Math.toRadians(from.latitude()); - double lat2 = Math.toRadians(to.latitude()); - double lon1 = Math.toRadians(from.longitude()); - double lon2 = Math.toRadians(to.longitude()); - - double dLon = lon2 - lon1; - - double y = Math.sin(dLon) * Math.cos(lat2); - double x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); - - double bearing = Math.toDegrees(Math.atan2(y, x)); - - // Normalize to [0, 360) - return (bearing + 360.0) % 360.0; - } - - /** - * Calculates the angular difference between two bearings. - *

    - * Returns the smallest angle between the two bearings, accounting for - * the circular nature of bearings (e.g., 10° and 350° are only 20° apart). - * - * @param bearing1 First bearing in degrees [0, 360) - * @param bearing2 Second bearing in degrees [0, 360) - * @return Smallest angular difference in degrees [0, 180] - */ - public static double bearingDifference(double bearing1, double bearing2) { - double diff = Math.abs(bearing1 - bearing2); - - // Take the smaller angle (handle wrap-around) - if (diff > 180.0) { - diff = 360.0 - diff; - } - - return diff; - } -} diff --git a/application/src/main/java/org/opentripplanner/framework/geometry/DirectionUtils.java b/application/src/main/java/org/opentripplanner/framework/geometry/DirectionUtils.java index 0a2bd6d0194..4724d4e4f12 100644 --- a/application/src/main/java/org/opentripplanner/framework/geometry/DirectionUtils.java +++ b/application/src/main/java/org/opentripplanner/framework/geometry/DirectionUtils.java @@ -23,6 +23,29 @@ public static double getAzimuth(Coordinate a, Coordinate b) { return Math.toDegrees(Math.atan2(dX, dY)); } + /** + * Calculates the angular difference between two bearings in degrees. + *

    + * Returns the smallest angle between the two bearings, accounting for the circular nature + * of angles (e.g., 10° and -170° are only 20° apart, not 180°). + *

    + * Works with any degree range (e.g., [0, 360) or [-180, 180]). + * + * @param bearing1 First bearing in degrees + * @param bearing2 Second bearing in degrees + * @return Smallest angular difference in degrees [0, 180] + */ + public static double bearingDifference(double bearing1, double bearing2) { + double diff = Math.abs(bearing1 - bearing2); + + // Take the smaller angle (handle wrap-around) + if (diff > 180.0) { + diff = 360.0 - diff; + } + + return diff; + } + /** * Computes the angle of the last segment of a LineString or MultiLineString in radians clockwise * from North in the range (-PI, PI). diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index 8c446525acf..21c6bc62b41 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -307,14 +307,15 @@ void findOptimalInsertion_pickupAtExistingPoint_handlesCorrectly() { // Expected: Segment A→B should be reused, B→dropoff and dropoff→C should be routed var stop1 = createStopAt(0, OSLO_EAST); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTHEAST); var mockPath = createMockGraphPath(Duration.ofMinutes(5)); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); - // Pickup exactly at OSLO_EAST (existing stop), dropoff at OSLO_MIDPOINT_NORTH (new) - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_MIDPOINT_NORTH); + // Pickup exactly at OSLO_EAST (existing stop), dropoff at OSLO_NORTH (new) + // OSLO_NORTH is directly on the way from OSLO_EAST to OSLO_NORTHEAST (same longitude as OSLO_EAST) + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_NORTH); assertNotNull(result, "Should find valid insertion"); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java deleted file mode 100644 index d44dd7922be..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/DirectionalCalculatorTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.opentripplanner.ext.carpooling.util; - -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; - -import org.junit.jupiter.api.Test; - -class DirectionalCalculatorTest { - - private static final double TOLERANCE = 5.0; // 5 degree tolerance for cardinal directions - - @Test - void calculateBearing_northward_returns0Degrees() { - // Oslo center to Oslo north should be ~0° (north) - double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_NORTH); - assertEquals(0.0, bearing, TOLERANCE); - } - - @Test - void calculateBearing_eastward_returns90Degrees() { - // Oslo center to Oslo east should be ~90° (east) - double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_EAST); - assertEquals(90.0, bearing, TOLERANCE); - } - - @Test - void calculateBearing_southward_returns180Degrees() { - double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_SOUTH); - assertEquals(180.0, bearing, TOLERANCE); - } - - @Test - void calculateBearing_westward_returns270Degrees() { - double bearing = DirectionalCalculator.calculateBearing(OSLO_CENTER, OSLO_WEST); - assertEquals(270.0, bearing, TOLERANCE); - } - - @Test - void bearingDifference_similarDirections_returnsSmallValue() { - // 10° and 20° should be 10° apart - double diff = DirectionalCalculator.bearingDifference(10.0, 20.0); - assertEquals(10.0, diff, 0.01); - } - - @Test - void bearingDifference_oppositeDirections_returns180() { - // North (0°) and South (180°) are 180° apart - double diff = DirectionalCalculator.bearingDifference(0.0, 180.0); - assertEquals(180.0, diff, 0.01); - } - - @Test - void bearingDifference_wrapAround_returnsShortestAngle() { - // 10° and 350° are only 20° apart (not 340°) - double diff = DirectionalCalculator.bearingDifference(10.0, 350.0); - assertEquals(20.0, diff, 0.01); - } - - @Test - void bearingDifference_reverse_returnsShortestAngle() { - // Should be symmetric - double diff1 = DirectionalCalculator.bearingDifference(10.0, 350.0); - double diff2 = DirectionalCalculator.bearingDifference(350.0, 10.0); - assertEquals(diff1, diff2, 0.01); - } -} diff --git a/application/src/test/java/org/opentripplanner/framework/geometry/DirectionUtilsTest.java b/application/src/test/java/org/opentripplanner/framework/geometry/DirectionUtilsTest.java index 7f56d5e001d..1f15c3239bf 100644 --- a/application/src/test/java/org/opentripplanner/framework/geometry/DirectionUtilsTest.java +++ b/application/src/test/java/org/opentripplanner/framework/geometry/DirectionUtilsTest.java @@ -83,4 +83,61 @@ public final void testAzimuth() { System.out.println("Max error in azimuth: " + maxError + " degrees."); assertTrue(maxError < 0.15); } + + @Test + public void bearingDifference_similarDirections_returnsSmallValue() { + // 10° and 20° should be 10° apart + double diff = DirectionUtils.bearingDifference(10.0, 20.0); + assertEquals(10.0, diff, 0.01); + } + + @Test + public void bearingDifference_oppositeDirections_returns180() { + // North (0°) and South (180°) are 180° apart + double diff = DirectionUtils.bearingDifference(0.0, 180.0); + assertEquals(180.0, diff, 0.01); + + // North (0°) and South (-180°) are also 180° apart + diff = DirectionUtils.bearingDifference(0.0, -180.0); + assertEquals(180.0, diff, 0.01); + } + + @Test + public void bearingDifference_wrapAround_returnsShortestAngle() { + // 10° and -10° are only 20° apart (wrap around at 0°) + double diff = DirectionUtils.bearingDifference(10.0, -10.0); + assertEquals(20.0, diff, 0.01); + + // Also test with positive wrap-around equivalent (350° is same as -10°) + diff = DirectionUtils.bearingDifference(10.0, 350.0); + assertEquals(20.0, diff, 0.01); + } + + @Test + public void bearingDifference_reverse_isSymmetric() { + // Should be symmetric + double diff1 = DirectionUtils.bearingDifference(10.0, -10.0); + double diff2 = DirectionUtils.bearingDifference(-10.0, 10.0); + assertEquals(diff1, diff2, 0.01); + + diff1 = DirectionUtils.bearingDifference(45.0, -45.0); + diff2 = DirectionUtils.bearingDifference(-45.0, 45.0); + assertEquals(diff1, diff2, 0.01); + } + + @Test + public void bearingDifference_worksWithBothRanges() { + // Test that it works with both [0, 360) and [-180, 180] ranges + + // Range [0, 360): 10° and 350° are 20° apart + double diff1 = DirectionUtils.bearingDifference(10.0, 350.0); + assertEquals(20.0, diff1, 0.01); + + // Range [-180, 180]: 10° and -10° are 20° apart (350° = -10° in this range) + double diff2 = DirectionUtils.bearingDifference(10.0, -10.0); + assertEquals(20.0, diff2, 0.01); + + // Both should give same result since 350° ≡ -10° + assertEquals(diff1, diff2, 0.01); + } } From 58bd9edb71a865807cc6b2b7760be0f6dd04b28f Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 09:14:23 +0100 Subject: [PATCH 20/40] Removes all star imports from carpool test code. --- .../PassengerDelayConstraintsTest.java | 5 ++- .../carpooling/filter/CapacityFilterTest.java | 12 +++++-- .../DirectionalCompatibilityFilterTest.java | 17 ++++++++-- .../filter/DistanceBasedFilterTest.java | 15 +++++++-- .../carpooling/filter/FilterChainTest.java | 16 +++++++--- .../filter/TimeBasedFilterTest.java | 10 ++++-- .../model/CarpoolTripCapacityTest.java | 13 ++++++-- .../routing/CarpoolStreetRouterTest.java | 7 ++-- .../routing/InsertionCandidateTest.java | 12 ++++--- .../routing/InsertionEvaluatorTest.java | 32 ++++++++++++++----- .../routing/InsertionPositionFinderTest.java | 15 +++++++-- .../carpooling/util/BeelineEstimatorTest.java | 10 ++++-- 12 files changed, 125 insertions(+), 39 deletions(-) diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java index aa5ddda3e8d..aed3089b9d9 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java @@ -1,6 +1,9 @@ package org.opentripplanner.ext.carpooling.constraints; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.opentripplanner.ext.carpooling.util.GraphPathUtils.calculateCumulativeDurations; import java.time.Duration; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java index af7d76077bc..aeca3bab34f 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java @@ -1,8 +1,14 @@ package org.opentripplanner.ext.carpooling.filter; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_SOUTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStop; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; import java.util.List; import org.junit.jupiter.api.BeforeEach; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java index 199a2806bab..7e3c2bdd4b9 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java @@ -1,8 +1,19 @@ package org.opentripplanner.ext.carpooling.filter; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +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.ext.carpooling.CarpoolTestCoordinates.LAKE_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.LAKE_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.LAKE_SOUTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.LAKE_WEST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTHEAST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStopAt; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops; import java.util.List; import org.junit.jupiter.api.BeforeEach; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java index 36c5ff2eb84..de4c526f272 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java @@ -1,8 +1,17 @@ package org.opentripplanner.ext.carpooling.filter; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +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.ext.carpooling.CarpoolTestCoordinates.LAKE_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.LAKE_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.LAKE_SOUTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.LAKE_WEST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStopAt; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java index eeae017c313..52f1748b29d 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java @@ -1,10 +1,18 @@ package org.opentripplanner.ext.carpooling.filter; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java index 776ebbe40e4..cedc95157f9 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java @@ -1,8 +1,12 @@ package org.opentripplanner.ext.carpooling.filter; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTHEAST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTripWithTime; import java.time.Duration; import java.time.ZonedDateTime; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java index 6d283977263..40f6e931e01 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java @@ -1,8 +1,15 @@ package org.opentripplanner.ext.carpooling.model; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStop; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java index 669b909f423..521deea92b9 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java @@ -1,7 +1,10 @@ package org.opentripplanner.ext.carpooling.routing; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java index 82645dc4768..c32a2d43cc4 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java @@ -1,9 +1,13 @@ package org.opentripplanner.ext.carpooling.routing; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +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.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.createMockGraphPaths; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithDeviationBudget; import java.time.Duration; import org.junit.jupiter.api.Test; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index 21c6bc62b41..633cf0578e7 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -1,11 +1,27 @@ package org.opentripplanner.ext.carpooling.routing; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_MIDPOINT_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTHEAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_SOUTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.createMockGraphPath; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStopAt; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithDeviationBudget; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops; import java.time.Duration; import java.util.List; @@ -91,9 +107,9 @@ void findOptimalInsertion_routingFails_skipsPosition() { // 2. First insertion attempt fails (null for first segment) // 3. Second insertion attempt succeeds (mockPath for all segments) when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath, mockPath) // Baseline (2 segments) - .thenReturn(null) // First insertion - segment fails - .thenReturn(mockPath, mockPath, mockPath, mockPath); // Second insertion succeeds + .thenReturn(mockPath, mockPath) + .thenReturn(null) + .thenReturn(mockPath, mockPath, mockPath, mockPath); // Use passenger coordinates that are compatible with trip direction (CENTER->EAST->NORTH) var result = findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java index 9a93e26ea6f..0509d8a39a7 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java @@ -1,8 +1,17 @@ package org.opentripplanner.ext.carpooling.routing; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; -import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_SOUTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStopAt; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops; import java.time.Duration; import java.util.List; diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java index c41214603f9..82a6500dce3 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java @@ -1,7 +1,13 @@ package org.opentripplanner.ext.carpooling.util; -import static org.junit.jupiter.api.Assertions.*; -import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTHEAST; +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTHWEST; import java.time.Duration; import java.util.List; From fcd4aa3f33905a15c9d2d44de2bcc69e3bf33420 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 09:40:16 +0100 Subject: [PATCH 21/40] Removes all trailing comments from carpool test code. --- .../carpooling/filter/CapacityFilterTest.java | 24 +++-- .../DirectionalCompatibilityFilterTest.java | 71 +++++++------- .../filter/DistanceBasedFilterTest.java | 27 ++++-- .../carpooling/filter/FilterChainTest.java | 12 ++- .../model/CarpoolTripCapacityTest.java | 96 ++++++++++++------- .../routing/CarpoolStreetRouterTest.java | 7 +- .../routing/InsertionCandidateTest.java | 41 +++++--- .../routing/InsertionEvaluatorTest.java | 18 ++-- .../routing/InsertionPositionFinderTest.java | 8 +- .../carpooling/util/BeelineEstimatorTest.java | 27 ++++-- 10 files changed, 201 insertions(+), 130 deletions(-) diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java index aeca3bab34f..08a22df4420 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java @@ -34,7 +34,8 @@ void accepts_tripWithCapacity_returnsTrue() { void accepts_tripAtFullCapacity_returnsTrue() { // CapacityFilter only checks configured capacity, not actual occupancy // Detailed capacity validation happens in the validator layer - var stop1 = createStop(0, +4); // All 4 seats taken + // All 4 seats taken + var stop1 = createStop(0, 4); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); // Filter accepts because trip has capacity configured (even if currently full) @@ -43,7 +44,8 @@ void accepts_tripAtFullCapacity_returnsTrue() { @Test void accepts_tripWithOneOpenSeat_returnsTrue() { - var stop1 = createStop(0, +3); // 3 of 4 seats taken + // 3 of 4 seats taken + var stop1 = createStop(0, 3); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); @@ -68,9 +70,12 @@ void accepts_passengerCoordinatesIgnored() { @Test void accepts_tripWithFluctuatingCapacity_checksOverallAvailability() { - var stop1 = createStop(0, +2); // 2 passengers - var stop2 = createStop(1, -2); // Dropoff 2 - var stop3 = createStop(2, +1); // Pickup 1 + // 2 passengers + var stop1 = createStop(0, 2); + // Dropoff 2 + var stop2 = createStop(1, -2); + // Pickup 1 + var stop3 = createStop(2, 1); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); // At some point there's capacity (positions 0, 2+) @@ -80,9 +85,12 @@ void accepts_tripWithFluctuatingCapacity_checksOverallAvailability() { @Test void accepts_tripAlwaysAtCapacity_returnsTrue() { // CapacityFilter only checks configured capacity, not actual occupancy - var stop1 = createStop(0, +4); // Fill to capacity - var stop2 = createStop(1, -1); // Drop 1 - var stop3 = createStop(2, +1); // Pick 1 (back to full) + // Fill to capacity + var stop1 = createStop(0, 4); + // Drop 1 + var stop2 = createStop(1, -1); + // Pick 1 (back to full) + var stop3 = createStop(2, 1); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); // Filter accepts because trip has capacity configured diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java index 7e3c2bdd4b9..962a58b6e47 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java @@ -31,24 +31,21 @@ void setup() { @Test void accepts_passengerAlignedWithTrip_returnsTrue() { - var trip = createSimpleTrip( - OSLO_CENTER, - OSLO_NORTH // Trip goes north - ); + // Trip goes north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger also going north var passengerPickup = OSLO_EAST; - var passengerDropoff = new WgsCoordinate(59.9549, 10.7922); // Northeast + // Northeast + var passengerDropoff = new WgsCoordinate(59.9549, 10.7922); assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); } @Test void accepts_passengerOppositeDirection_returnsFalse() { - var trip = createSimpleTrip( - OSLO_CENTER, - OSLO_NORTH // Trip goes north - ); + // Trip goes north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger going south var passengerPickup = OSLO_EAST; @@ -65,8 +62,10 @@ void accepts_tripAroundLake_passengerOnSegment_returnsTrue() { var trip = createTripWithStops(LAKE_NORTH, List.of(stop1, stop2), LAKE_WEST); // Passenger aligned with the southward segment (East → South) - var passengerPickup = new WgsCoordinate(59.9339, 10.7922); // East side - var passengerDropoff = new WgsCoordinate(59.9139, 10.7922); // South of east + // East side + var passengerPickup = new WgsCoordinate(59.9339, 10.7922); + // South of east + var passengerDropoff = new WgsCoordinate(59.9139, 10.7922); // Should accept because passenger aligns with East→South segment assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); @@ -77,7 +76,8 @@ void accepts_passengerFarFromRoute_butDirectionallyAligned_returnsTrue() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger far to the east but directionally aligned (both going north) - var passengerPickup = new WgsCoordinate(59.9139, 11.0000); // Way east + // Way east + var passengerPickup = new WgsCoordinate(59.9139, 11.0000); var passengerDropoff = new WgsCoordinate(59.9439, 11.0000); // Should accept - only checks direction, not distance (that's DistanceBasedFilter's job) @@ -86,33 +86,31 @@ void accepts_passengerFarFromRoute_butDirectionallyAligned_returnsTrue() { @Test void accepts_passengerPartiallyAligned_withinTolerance_returnsTrue() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + // Going north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger going northeast (~45° off) - var passengerPickup = OSLO_CENTER; - var passengerDropoff = OSLO_NORTHEAST; - // Should accept within default tolerance (60°) - assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); + assertTrue(filter.accepts(trip, OSLO_CENTER, OSLO_NORTHEAST)); } @Test void accepts_passengerPerpendicularToTrip_returnsFalse() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + // Going north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger going east (90° perpendicular) - var passengerPickup = OSLO_CENTER; - var passengerDropoff = OSLO_EAST; - // Should reject (exceeds 60° tolerance) - assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); + assertFalse(filter.accepts(trip, OSLO_CENTER, OSLO_EAST)); } @Test void accepts_complexRoute_multipleSegments_findsCompatibleSegment() { // Trip with multiple segments going different directions - var stop1 = createStopAt(0, OSLO_EAST); // Go east first - var stop2 = createStopAt(1, OSLO_NORTHEAST); // Then northeast + // Go east first + var stop1 = createStopAt(0, OSLO_EAST); + // Then northeast + var stop2 = createStopAt(1, OSLO_NORTHEAST); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); // Passenger going northeast (aligns with second segment) @@ -136,11 +134,14 @@ void accepts_tripWithSingleStop_checksAllSegments() { @Test void accepts_passengerWithinCorridorButWrongDirection_returnsFalse() { - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + // Going north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger nearby but going opposite direction - var passengerPickup = new WgsCoordinate(59.9239, 10.7522); // North - var passengerDropoff = new WgsCoordinate(59.9139, 10.7522); // South (backtracking) + // North + var passengerPickup = new WgsCoordinate(59.9239, 10.7522); + // South (backtracking) + var passengerDropoff = new WgsCoordinate(59.9139, 10.7522); assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); } @@ -150,14 +151,12 @@ void customBearingTolerance_acceptsWithinCustomTolerance() { // Custom filter with 90° tolerance (very permissive) var customFilter = new DirectionalCompatibilityFilter(90.0); - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + // Going north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger going east (90° perpendicular) - var passengerPickup = OSLO_CENTER; - var passengerDropoff = OSLO_EAST; - // Should accept with 90° tolerance (default 60° would reject) - assertTrue(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + assertTrue(customFilter.accepts(trip, OSLO_CENTER, OSLO_EAST)); } @Test @@ -165,14 +164,12 @@ void customBearingTolerance_rejectsOutsideCustomTolerance() { // Custom filter with 30° tolerance (strict) var customFilter = new DirectionalCompatibilityFilter(30.0); - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Going north + // Going north + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger going northeast (~45° off) - var passengerPickup = OSLO_CENTER; - var passengerDropoff = OSLO_NORTHEAST; - // Should reject with 30° tolerance (default 60° would accept) - assertFalse(customFilter.accepts(trip, passengerPickup, passengerDropoff)); + assertFalse(customFilter.accepts(trip, OSLO_CENTER, OSLO_NORTHEAST)); } @Test diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java index de4c526f272..d493eb6f599 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java @@ -60,8 +60,10 @@ void rejects_passengerPerpendicularToRoute_farAway_returnsFalse() { // Passenger journey perpendicular to the route, far to the west // > 50km perpendicular distance from the route line - var passengerPickup = new WgsCoordinate(59.9139, 9.5); // Far west - var passengerDropoff = new WgsCoordinate(59.9549, 9.5); // Still far west + // Far west + var passengerPickup = new WgsCoordinate(59.9139, 9.5); + // Still far west + var passengerDropoff = new WgsCoordinate(59.9549, 9.5); assertFalse(filter.accepts(trip, passengerPickup, passengerDropoff)); } @@ -87,8 +89,10 @@ void rejects_oneLocationNear_otherLocationFar_returnsTrue() { // Pickup on the route, but dropoff far to the north (>50km perpendicular) // At this latitude, 0.5° latitude ≈ 55km - var passengerPickup = new WgsCoordinate(59.9, 10.75); // On route - var passengerDropoff = new WgsCoordinate(59.9 + 0.5, 10.75); // Far north + // On route + var passengerPickup = new WgsCoordinate(59.9, 10.75); + // Far north + var passengerDropoff = new WgsCoordinate(59.9 + 0.5, 10.75); // Should accept because only one location must be near the route assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); @@ -114,8 +118,10 @@ void accepts_passengerNearRouteEndpoints_returnsTrue() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Passenger very close to trip start and end points - var passengerPickup = new WgsCoordinate(59.914, 10.753); // Very close to start - var passengerDropoff = new WgsCoordinate(59.954, 10.791); // Very close to end + // Very close to start + var passengerPickup = new WgsCoordinate(59.914, 10.753); + // Very close to end + var passengerDropoff = new WgsCoordinate(59.954, 10.791); assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); } @@ -213,8 +219,10 @@ void accepts_tripWithMultipleStops_passengerNearAnySegment() { var trip = createTripWithStops(LAKE_NORTH, java.util.List.of(stop1, stop2), LAKE_WEST); // Passenger journey near the LAKE_SOUTH to LAKE_WEST segment - var passengerPickup = new WgsCoordinate(59.9139, 10.735); // Near SOUTH - var passengerDropoff = new WgsCoordinate(59.9139, 10.720); // Near WEST + // Near SOUTH + var passengerPickup = new WgsCoordinate(59.9139, 10.735); + // Near WEST + var passengerDropoff = new WgsCoordinate(59.9139, 10.720); // Should accept if close to any segment of the route assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); @@ -227,7 +235,8 @@ void accepts_sameStartEnd_passengerAtSameLocation_returnsTrue() { var trip = createSimpleTrip(sameLocation, sameLocation); // Passenger at the same location - var passengerPickup = new WgsCoordinate(59.901, 10.751); // Very close + // Very close + var passengerPickup = new WgsCoordinate(59.901, 10.751); var passengerDropoff = new WgsCoordinate(59.902, 10.752); assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff)); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java index 52f1748b29d..56b715e3fb5 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java @@ -42,7 +42,8 @@ void accepts_oneFilterRejects_returnsFalse() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); when(filter1.accepts(any(), any(), any())).thenReturn(true); - when(filter2.accepts(any(), any(), any())).thenReturn(false); // Rejects + // Rejects + when(filter2.accepts(any(), any(), any())).thenReturn(false); var chain = new FilterChain(List.of(filter1, filter2)); @@ -57,7 +58,8 @@ void accepts_shortCircuits_afterFirstRejection() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); when(filter1.accepts(any(), any(), any())).thenReturn(true); - when(filter2.accepts(any(), any(), any())).thenReturn(false); // Rejects + // Rejects + when(filter2.accepts(any(), any(), any())).thenReturn(false); // filter3 should not be called var chain = new FilterChain(List.of(filter1, filter2, filter3)); @@ -65,7 +67,8 @@ void accepts_shortCircuits_afterFirstRejection() { verify(filter1).accepts(any(), any(), any()); verify(filter2).accepts(any(), any(), any()); - verify(filter3, never()).accepts(any(), any(), any()); // Not called + // Not called + verify(filter3, never()).accepts(any(), any(), any()); } @Test @@ -74,7 +77,8 @@ void accepts_firstFilterRejects_doesNotCallOthers() { var filter2 = mock(TripFilter.class); var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - when(filter1.accepts(any(), any(), any())).thenReturn(false); // First rejects + // First rejects + when(filter1.accepts(any(), any(), any())).thenReturn(false); var chain = new FilterChain(List.of(filter1, filter2)); chain.accepts(trip, OSLO_EAST, OSLO_WEST); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java index 40f6e931e01..a83a9eb98ae 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java @@ -23,46 +23,63 @@ class CarpoolTripCapacityTest { void getPassengerCountAtPosition_noStops_allZeros() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - assertEquals(0, trip.getPassengerCountAtPosition(0)); // Boarding - assertEquals(0, trip.getPassengerCountAtPosition(1)); // Beyond stops + // Boarding + assertEquals(0, trip.getPassengerCountAtPosition(0)); + // Beyond stops + assertEquals(0, trip.getPassengerCountAtPosition(1)); } @Test void getPassengerCountAtPosition_onePickupStop_incrementsAtStop() { - var stop1 = createStop(0, +1); // Pickup 1 passenger + // Pickup 1 passenger + var stop1 = createStop(0, 1); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); - assertEquals(0, trip.getPassengerCountAtPosition(0)); // Before stop - assertEquals(1, trip.getPassengerCountAtPosition(1)); // After stop - assertEquals(0, trip.getPassengerCountAtPosition(2)); // Alighting + // Before stop + assertEquals(0, trip.getPassengerCountAtPosition(0)); + // After stop + assertEquals(1, trip.getPassengerCountAtPosition(1)); + // Alighting + assertEquals(0, trip.getPassengerCountAtPosition(2)); } @Test void getPassengerCountAtPosition_pickupAndDropoff_incrementsThenDecrements() { - var stop1 = createStop(0, +2); // Pickup 2 passengers - var stop2 = createStop(1, -1); // Dropoff 1 passenger + // Pickup 2 passengers + var stop1 = createStop(0, 2); + // Dropoff 1 passenger + var stop2 = createStop(1, -1); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - assertEquals(0, trip.getPassengerCountAtPosition(0)); // Before any stops - assertEquals(2, trip.getPassengerCountAtPosition(1)); // After first pickup - assertEquals(1, trip.getPassengerCountAtPosition(2)); // After dropoff - assertEquals(0, trip.getPassengerCountAtPosition(3)); // Alighting + // Before any stops + assertEquals(0, trip.getPassengerCountAtPosition(0)); + // After first pickup + assertEquals(2, trip.getPassengerCountAtPosition(1)); + // After dropoff + assertEquals(1, trip.getPassengerCountAtPosition(2)); + // Alighting + assertEquals(0, trip.getPassengerCountAtPosition(3)); } @Test void getPassengerCountAtPosition_multipleStops_cumulativeCount() { - var stop1 = createStop(0, +1); - var stop2 = createStop(1, +2); + var stop1 = createStop(0, 1); + var stop2 = createStop(1, 2); var stop3 = createStop(2, -1); - var stop4 = createStop(3, +1); + var stop4 = createStop(3, 1); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3, stop4), OSLO_NORTH); assertEquals(0, trip.getPassengerCountAtPosition(0)); - assertEquals(1, trip.getPassengerCountAtPosition(1)); // 0 + 1 - assertEquals(3, trip.getPassengerCountAtPosition(2)); // 1 + 2 - assertEquals(2, trip.getPassengerCountAtPosition(3)); // 3 - 1 - assertEquals(3, trip.getPassengerCountAtPosition(4)); // 2 + 1 - assertEquals(0, trip.getPassengerCountAtPosition(5)); // Alighting + // 0 + 1 + assertEquals(1, trip.getPassengerCountAtPosition(1)); + // 1 + 2 + assertEquals(3, trip.getPassengerCountAtPosition(2)); + // 3 - 1 + assertEquals(2, trip.getPassengerCountAtPosition(3)); + // 2 + 1 + assertEquals(3, trip.getPassengerCountAtPosition(4)); + // Alighting + assertEquals(0, trip.getPassengerCountAtPosition(5)); } @Test @@ -74,8 +91,8 @@ void getPassengerCountAtPosition_negativePosition_throwsException() { @Test void getPassengerCountAtPosition_positionTooLarge_throwsException() { - var stop1 = createStop(0, +1); - var stop2 = createStop(1, +1); + var stop1 = createStop(0, 1); + var stop2 = createStop(1, 1); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); // Valid positions are 0 to 3 (stops.size() + 1) @@ -90,12 +107,14 @@ void hasCapacityForInsertion_noPassengers_hasCapacity() { var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); assertTrue(trip.hasCapacityForInsertion(1, 2, 1)); - assertTrue(trip.hasCapacityForInsertion(1, 2, 4)); // Can fit all 4 seats + // Can fit all 4 seats + assertTrue(trip.hasCapacityForInsertion(1, 2, 4)); } @Test void hasCapacityForInsertion_fullCapacity_noCapacity() { - var stop1 = createStop(0, +4); // Fill all 4 seats + // Fill all 4 seats + var stop1 = createStop(0, 4); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); // No room for additional passenger after stop 1 @@ -104,17 +123,21 @@ void hasCapacityForInsertion_fullCapacity_noCapacity() { @Test void hasCapacityForInsertion_partialCapacity_hasCapacityForOne() { - var stop1 = createStop(0, +3); // 3 of 4 seats taken + // 3 of 4 seats taken + var stop1 = createStop(0, 3); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); - assertTrue(trip.hasCapacityForInsertion(2, 3, 1)); // Room for 1 - assertFalse(trip.hasCapacityForInsertion(2, 3, 2)); // No room for 2 + // Room for 1 + assertTrue(trip.hasCapacityForInsertion(2, 3, 1)); + // No room for 2 + assertFalse(trip.hasCapacityForInsertion(2, 3, 2)); } @Test void hasCapacityForInsertion_acrossMultiplePositions_checksAll() { - var stop1 = createStop(0, +2); - var stop2 = createStop(1, +1); // Total 3 passengers at position 2 + var stop1 = createStop(0, 2); + // Total 3 passengers at position 2 + var stop2 = createStop(1, 1); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); // Range 1-3 includes position with 3 passengers, so only 1 seat available @@ -124,7 +147,8 @@ void hasCapacityForInsertion_acrossMultiplePositions_checksAll() { @Test void hasCapacityForInsertion_rangeBeforeStop_usesInitialCapacity() { - var stop1 = createStop(0, +4); // Fill capacity at position 1 + // Fill capacity at position 1 + var stop1 = createStop(0, 4); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); // Pickup at position 1, dropoff at position 1 - only checks capacity at boarding (position 0) @@ -134,12 +158,16 @@ void hasCapacityForInsertion_rangeBeforeStop_usesInitialCapacity() { @Test void hasCapacityForInsertion_capacityFreesUpInRange_checksMaxInRange() { - var stop1 = createStop(0, +3); // 3 passengers - var stop2 = createStop(1, -2); // 2 dropoff, leaving 1 + // 3 passengers + var stop1 = createStop(0, 3); + // 2 dropoff, leaving 1 + var stop2 = createStop(1, -2); var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); // Range includes both positions - max passengers is 3 (at position 1) - assertTrue(trip.hasCapacityForInsertion(1, 3, 1)); // 4 total - 3 max = 1 available - assertFalse(trip.hasCapacityForInsertion(1, 3, 2)); // Not enough + // 4 total - 3 max = 1 available + assertTrue(trip.hasCapacityForInsertion(1, 3, 1)); + // Not enough + assertFalse(trip.hasCapacityForInsertion(1, 3, 2)); } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java index 521deea92b9..56e48dca7c2 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java @@ -52,15 +52,16 @@ void setup() { @Test void constructor_storesDependencies() { assertNotNull(router); - // Router should be successfully constructed with all dependencies } @Test void route_withValidLocations_returnsNonNull() { // This is a basic smoke test - actual routing behavior depends on // the graph and is tested via integration tests - var from = GenericLocation.fromCoordinate(59.9139, 10.7522); // Oslo center - var to = GenericLocation.fromCoordinate(59.9149, 10.7522); // Oslo north + // Oslo center + var from = GenericLocation.fromCoordinate(59.9139, 10.7522); + // Oslo north + var to = GenericLocation.fromCoordinate(59.9149, 10.7522); // Note: Without a real graph, this will likely return null // The important thing is that it doesn't throw exceptions diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java index c32a2d43cc4..d2ef34b9143 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java @@ -17,15 +17,16 @@ class InsertionCandidateTest { @Test void additionalDuration_calculatesCorrectly() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(3); // 3 segments + // 3 segments + var segments = createMockGraphPaths(3); var candidate = new InsertionCandidate( trip, - 1, // pickup position - 2, // dropoff position + 1, + 2, segments, - Duration.ofMinutes(10), // baseline - Duration.ofMinutes(15) // total + Duration.ofMinutes(10), + Duration.ofMinutes(15) ); assertEquals(Duration.ofMinutes(5), candidate.additionalDuration()); @@ -42,7 +43,8 @@ void additionalDuration_zeroAdditional_returnsZero() { 2, segments, Duration.ofMinutes(10), - Duration.ofMinutes(10) // Same as baseline + // Same as baseline + Duration.ofMinutes(10) ); assertEquals(Duration.ZERO, candidate.additionalDuration()); @@ -58,8 +60,10 @@ void isWithinDeviationBudget_withinBudget_returnsTrue() { 1, 2, segments, - Duration.ofMinutes(10), // baseline - Duration.ofMinutes(18) // total (8 min additional, within 10 min budget) + // baseline + Duration.ofMinutes(10), + // total (8 min additional, within 10 min budget) + Duration.ofMinutes(18) ); assertTrue(candidate.isWithinDeviationBudget()); @@ -75,8 +79,10 @@ void isWithinDeviationBudget_exceedsBudget_returnsFalse() { 1, 2, segments, - Duration.ofMinutes(10), // baseline - Duration.ofMinutes(20) // total (10 min additional, exceeds 5 min budget) + // baseline + Duration.ofMinutes(10), + // total (10 min additional, exceeds 5 min budget) + Duration.ofMinutes(20) ); assertFalse(candidate.isWithinDeviationBudget()); @@ -93,7 +99,8 @@ void isWithinDeviationBudget_exactlyAtBudget_returnsTrue() { 2, segments, Duration.ofMinutes(10), - Duration.ofMinutes(15) // Exactly 5 min additional + // Exactly 5 min additional + Duration.ofMinutes(15) ); assertTrue(candidate.isWithinDeviationBudget()); @@ -114,7 +121,8 @@ void getPickupSegments_returnsCorrectRange() { ); var pickupSegments = candidate.getPickupSegments(); - assertEquals(2, pickupSegments.size()); // Segments 0-1 (before position 2) + // Segments 0-1 (before position 2) + assertEquals(2, pickupSegments.size()); assertEquals(segments.subList(0, 2), pickupSegments); } @@ -151,7 +159,8 @@ void getSharedSegments_returnsCorrectRange() { ); var sharedSegments = candidate.getSharedSegments(); - assertEquals(2, sharedSegments.size()); // Segments 1-2 (positions 1 to 3) + // Segments 1-2 (positions 1 to 3) + assertEquals(2, sharedSegments.size()); assertEquals(segments.subList(1, 3), sharedSegments); } @@ -188,7 +197,8 @@ void getDropoffSegments_returnsCorrectRange() { ); var dropoffSegments = candidate.getDropoffSegments(); - assertEquals(2, dropoffSegments.size()); // Segments 3-4 (after position 3) + // Segments 3-4 (after position 3) + assertEquals(2, dropoffSegments.size()); assertEquals(segments.subList(3, 5), dropoffSegments); } @@ -227,7 +237,8 @@ void toString_includesKeyInformation() { var str = candidate.toString(); assertTrue(str.contains("pickup@1")); assertTrue(str.contains("dropoff@2")); - assertTrue(str.contains("300s")); // 5 min = 300s additional + // 5 min = 300s additional + assertTrue(str.contains("300s")); assertTrue(str.contains("segments=3")); } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index 633cf0578e7..f4901939c26 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -176,10 +176,13 @@ void findOptimalInsertion_selectsMinimumAdditionalDuration() { // Use thenAnswer to provide consistent route times // Just return paths with reasonable durations for all calls + // Baseline + // First insertion (15 min total, 5 min additional) + // Second insertion (18 min total, 8 min additional) when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath10) // Baseline - .thenReturn(mockPath4, mockPath5, mockPath6) // First insertion (15 min total, 5 min additional) - .thenReturn(mockPath5, mockPath6, mockPath7); // Second insertion (18 min total, 8 min additional) + .thenReturn(mockPath10) + .thenReturn(mockPath4, mockPath5, mockPath6) + .thenReturn(mockPath5, mockPath6, mockPath7); var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); @@ -224,9 +227,12 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { // Modified route segments should have DIFFERENT durations // If baseline is incorrectly reused, we'd see 10 min for A→C segment - var segmentAC = createMockGraphPath(Duration.ofMinutes(3)); // CENTER → EAST - var segmentCD = createMockGraphPath(Duration.ofMinutes(2)); // EAST → MIDPOINT_NORTH - var segmentDB = createMockGraphPath(Duration.ofMinutes(4)); // MIDPOINT_NORTH → NORTH + // CENTER → EAST + var segmentAC = createMockGraphPath(Duration.ofMinutes(3)); + // EAST → MIDPOINT_NORTH + var segmentCD = createMockGraphPath(Duration.ofMinutes(2)); + // MIDPOINT_NORTH → NORTH + var segmentDB = createMockGraphPath(Duration.ofMinutes(4)); // Setup routing mock: return all segment mocks for any routing call // The algorithm will evaluate multiple insertion positions diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java index 0509d8a39a7..acd83d64277 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java @@ -80,11 +80,9 @@ void findViablePositions_exceedsBeelineDelay_rejectsPosition() { var trip = createTripWithStops(OSLO_CENTER, List.of(createStopAt(0, OSLO_EAST)), OSLO_NORTH); // Try to insert passenger that would cause significant detour - var viablePositions = restrictiveFinder.findViablePositions( - trip, - OSLO_WEST, // Far from route - OSLO_SOUTH // Even farther - ); + // Far from route + // Even farther + var viablePositions = restrictiveFinder.findViablePositions(trip, OSLO_WEST, OSLO_SOUTH); // With very restrictive constraints, positions causing significant detours should be rejected // However, the beeline check only applies if there are existing stops (routePoints.size() > 2) diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java index 82a6500dce3..33f5d8d86ec 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java @@ -32,8 +32,10 @@ void estimateDuration_shortDistance_returnsReasonableDuration() { // With default parameters (1.3 detour factor, 10 m/s speed): // Expected: ~2500m * 1.3 / 10 = ~325 seconds = ~5.4 minutes - assertTrue(duration.getSeconds() > 240); // > 4 minutes - assertTrue(duration.getSeconds() < 480); // < 8 minutes + // > 4 minutes + assertTrue(duration.getSeconds() > 240); + // < 8 minutes + assertTrue(duration.getSeconds() < 480); } @Test @@ -42,8 +44,10 @@ void estimateDuration_mediumDistance_returnsReasonableDuration() { Duration duration = estimator.estimateDuration(OSLO_CENTER, OSLO_NORTH); // Expected: ~3300m * 1.3 / 10 = ~429 seconds = ~7.2 minutes - assertTrue(duration.getSeconds() > 300); // > 5 minutes - assertTrue(duration.getSeconds() < 600); // < 10 minutes + // > 5 minutes + assertTrue(duration.getSeconds() > 300); + // < 10 minutes + assertTrue(duration.getSeconds() < 600); } @Test @@ -72,15 +76,18 @@ void calculateCumulativeTimes_simpleRoute_calculatesCorrectly() { Duration[] times = estimator.calculateCumulativeTimes(points); assertEquals(3, times.length); - assertEquals(Duration.ZERO, times[0]); // Start at 0 + // Start at 0 + assertEquals(Duration.ZERO, times[0]); // Each segment should add positive duration assertTrue(times[1].compareTo(Duration.ZERO) > 0); assertTrue(times[2].compareTo(times[1]) > 0); // Total duration should be reasonable (sum of two ~3-5km segments) - assertTrue(times[2].getSeconds() > 600); // > 10 minutes - assertTrue(times[2].getSeconds() < 1800); // < 30 minutes + // > 10 minutes + assertTrue(times[2].getSeconds() > 600); + // < 30 minutes + assertTrue(times[2].getSeconds() < 1800); } @Test @@ -240,8 +247,10 @@ void estimateDuration_longDistance_scalesCorrectly() { // Expected: ~300,000m * 1.3 / 10 = 39,000 seconds = 650 minutes // This is just a sanity check - beeline is not accurate for such long distances - assertTrue(duration.getSeconds() > 30000); // > 8.3 hours - assertTrue(duration.getSeconds() < 60000); // < 16.7 hours + // > 8.3 hours + assertTrue(duration.getSeconds() > 30000); + // < 16.7 hours + assertTrue(duration.getSeconds() < 60000); } @Test From 16cd187de1000604d34cd55a89392dabb9efaf92 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 10:26:43 +0100 Subject: [PATCH 22/40] Removes CarpoolStreetRouterTest. It doesn't test functionality in any detail and can easily be removed. --- .../routing/CarpoolStreetRouterTest.java | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java deleted file mode 100644 index 56e48dca7c2..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouterTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.opentripplanner.ext.carpooling.routing; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.opentripplanner.model.GenericLocation; -import org.opentripplanner.routing.api.request.RouteRequest; -import org.opentripplanner.routing.api.request.preference.RoutingPreferences; -import org.opentripplanner.routing.api.request.preference.StreetPreferences; -import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.routing.linking.VertexLinker; -import org.opentripplanner.street.service.StreetLimitationParametersService; - -/** - * Unit tests for {@link CarpoolStreetRouter}. - *

    - * These tests verify the router in isolation by mocking all dependencies. - */ -class CarpoolStreetRouterTest { - - private Graph mockGraph; - private VertexLinker mockVertexLinker; - private StreetLimitationParametersService mockStreetService; - private RouteRequest mockRequest; - private RoutingPreferences mockPreferences; - private StreetPreferences mockStreetPreferences; - private CarpoolStreetRouter router; - - @BeforeEach - void setup() { - mockGraph = mock(Graph.class); - mockVertexLinker = mock(VertexLinker.class); - mockStreetService = mock(StreetLimitationParametersService.class); - mockRequest = mock(RouteRequest.class); - mockPreferences = mock(RoutingPreferences.class); - mockStreetPreferences = mock(StreetPreferences.class); - - // Setup mock chain for preferences - when(mockRequest.preferences()).thenReturn(mockPreferences); - when(mockPreferences.street()).thenReturn(mockStreetPreferences); - when(mockRequest.arriveBy()).thenReturn(false); - when(mockStreetService.getMaxCarSpeed()).thenReturn(30.0f); - - router = new CarpoolStreetRouter(mockGraph, mockVertexLinker, mockStreetService, mockRequest); - } - - @Test - void constructor_storesDependencies() { - assertNotNull(router); - } - - @Test - void route_withValidLocations_returnsNonNull() { - // This is a basic smoke test - actual routing behavior depends on - // the graph and is tested via integration tests - // Oslo center - var from = GenericLocation.fromCoordinate(59.9139, 10.7522); - // Oslo north - var to = GenericLocation.fromCoordinate(59.9149, 10.7522); - - // Note: Without a real graph, this will likely return null - // The important thing is that it doesn't throw exceptions - var result = router.route(from, to); - // Result can be null if routing fails (expected with mock graph) - // What matters is no exceptions were thrown - } - - @Test - void route_withNullFrom_handlesGracefully() { - var to = GenericLocation.fromCoordinate(59.9149, 10.7522); - - // Should handle null gracefully (return null, not throw) - var result = router.route(null, to); - - // Result should be null (routing failed) - assertNull(result); - } - - @Test - void route_withNullTo_handlesGracefully() { - var from = GenericLocation.fromCoordinate(59.9139, 10.7522); - - // Should handle null gracefully (return null, not throw) - var result = router.route(from, null); - - // Result should be null (routing failed) - assertNull(result); - } - - @Test - void route_multipleCallsSameLocations_behavesConsistently() { - var from = GenericLocation.fromCoordinate(59.9139, 10.7522); - var to = GenericLocation.fromCoordinate(59.9149, 10.7522); - - var result1 = router.route(from, to); - var result2 = router.route(from, to); - - // Results should be consistent (both null or both non-null) - assertEquals(result1 == null, result2 == null); - } - - @Test - void route_multipleDifferentCalls_routesIndependently() { - var from1 = GenericLocation.fromCoordinate(59.9139, 10.7522); - var to1 = GenericLocation.fromCoordinate(59.9149, 10.7522); - var from2 = GenericLocation.fromCoordinate(59.9159, 10.7522); - var to2 = GenericLocation.fromCoordinate(59.9169, 10.7522); - - // Should be able to route multiple different pairs - var result1 = router.route(from1, to1); - var result2 = router.route(from2, to2); - // Both routes should complete without exceptions - // Results may be null with mock graph, but no exceptions - } -} From 62809f3aa10986e41ccba941e76250142f250105 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 12:37:01 +0100 Subject: [PATCH 23/40] Removes MockGraphPathFactory.java and makes use of real graph path building instead. --- .../carpooling/CarpoolGraphPathBuilder.java | 67 +++++++++ .../ext/carpooling/MockGraphPathFactory.java | 68 --------- .../PassengerDelayConstraintsTest.java | 141 +++++++++--------- .../routing/InsertionCandidateTest.java | 26 ++-- .../routing/InsertionEvaluatorTest.java | 65 ++++---- 5 files changed, 186 insertions(+), 181 deletions(-) create mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolGraphPathBuilder.java delete mode 100644 application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolGraphPathBuilder.java b/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolGraphPathBuilder.java new file mode 100644 index 00000000000..c5a9d22dd79 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolGraphPathBuilder.java @@ -0,0 +1,67 @@ +package org.opentripplanner.ext.carpooling; + +import java.time.Duration; +import java.util.List; +import java.util.stream.IntStream; +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; +import org.opentripplanner.street.search.state.TestStateBuilder; + +/** + * Builder for creating GraphPath objects for carpooling tests using real State chains. + * This replaces MockGraphPathFactory with OTP's preferred TestStateBuilder pattern. + */ +public class CarpoolGraphPathBuilder { + + // Walking speed in m/s (OTP default from WalkPreferences) + private static final double WALKING_SPEED_MPS = 1.33; + + // Default number of edges to distribute duration across + private static final int DEFAULT_NUM_EDGES = 3; + + /** + * Creates a GraphPath with default 5-minute duration. + */ + public static GraphPath createGraphPath() { + return createGraphPath(Duration.ofMinutes(5)); + } + + /** + * Creates a GraphPath with specified duration using State chain. + * + * @param duration Total duration for the path + * @return GraphPath with real State objects and accurate timing + */ + public static GraphPath createGraphPath(Duration duration) { + var builder = TestStateBuilder.ofWalking(); + + // Calculate distance needed for target duration + double totalDistanceMeters = duration.toSeconds() * WALKING_SPEED_MPS; + + // Distribute across multiple edges for realistic path + int numEdges = DEFAULT_NUM_EDGES; + int distancePerEdge = (int) Math.ceil(totalDistanceMeters / numEdges); + + // Build state chain with calculated distances + for (int i = 0; i < numEdges; i++) { + builder.streetEdge("segment-" + i, distancePerEdge); + } + + return new GraphPath<>(builder.build()); + } + + /** + * Creates multiple GraphPaths with varying durations. + * Each path has duration = 5 minutes + index minutes. + * + * @param count Number of paths to create + * @return List of GraphPaths with incrementing durations + */ + public static List> createGraphPaths(int count) { + return IntStream.range(0, count) + .mapToObj(i -> createGraphPath(Duration.ofMinutes(5 + i))) + .toList(); + } +} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java b/application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java deleted file mode 100644 index 040e9aa5f61..00000000000 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/MockGraphPathFactory.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.opentripplanner.ext.carpooling; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.state.State; - -/** - * Factory for creating mock GraphPath objects for testing. - */ -public class MockGraphPathFactory { - - /** - * Creates a mock GraphPath with default 5-minute duration. - */ - public static GraphPath createMockGraphPath() { - return createMockGraphPath(Duration.ofMinutes(5)); - } - - /** - * Creates a mock GraphPath with specified duration. - */ - @SuppressWarnings("unchecked") - public static GraphPath createMockGraphPath(Duration duration) { - var mockPath = (GraphPath) mock(GraphPath.class); - - // Set public fields directly instead of stubbing to avoid Mockito state issues - mockPath.states = new java.util.LinkedList<>(createMockStates(duration)); - mockPath.edges = new java.util.LinkedList(); - - return mockPath; - } - - /** - * Creates mock State objects with specified time duration. - */ - private static List createMockStates(Duration duration) { - var startState = mock(State.class); - var endState = mock(State.class); - - var startTime = Instant.now(); - var endTime = startTime.plus(duration); - - when(startState.getTime()).thenReturn(startTime); - when(endState.getTime()).thenReturn(endTime); - - var mockVertex = mock(Vertex.class); - when(startState.getVertex()).thenReturn(mockVertex); - when(endState.getVertex()).thenReturn(mockVertex); - - return List.of(startState, endState); - } - - /** - * Creates multiple mock GraphPaths with varying durations. - */ - public static List> createMockGraphPaths(int count) { - return java.util.stream.IntStream.range(0, count) - .mapToObj(i -> createMockGraphPath(Duration.ofMinutes(5 + i))) - .toList(); - } -} diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java index aed3089b9d9..8364132d2e5 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.ext.carpooling.MockGraphPathFactory; +import org.opentripplanner.ext.carpooling.CarpoolGraphPathBuilder; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.state.State; @@ -30,9 +30,9 @@ void satisfiesConstraints_noExistingStops_alwaysAccepts() { // Modified route with passenger inserted GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(7)), }; // Should accept - no existing passengers to protect @@ -55,10 +55,10 @@ void satisfiesConstraints_delayWellUnderThreshold_accepts() { // Timings: 0min -> 3min -> 7min -> 12min -> 17min // Stop1 delay: 7min - 5min = 2min (well under 5min threshold) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(4)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(4)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertTrue( @@ -80,10 +80,10 @@ void satisfiesConstraints_delayExactlyAtThreshold_accepts() { // Timings: 0min -> 5min -> 15min -> 20min -> 25min // Stop1 delay: 15min - 10min = 5min (exactly at threshold) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertTrue( @@ -105,10 +105,10 @@ void satisfiesConstraints_delayOverThreshold_rejects() { // Timings: 0min -> 5min -> 16min -> 21min -> 26min // Stop1 delay: 16min - 10min = 6min (exceeds threshold) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(11)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertFalse( @@ -136,11 +136,11 @@ void satisfiesConstraints_multipleStops_oneOverThreshold_rejects() { // Stop1 delay: 13min - 10min = 3min ✓ // Stop2 delay: 27min - 20min = 7min ✗ GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(9)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(8)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(9)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertFalse( @@ -168,11 +168,11 @@ void satisfiesConstraints_multipleStops_allUnderThreshold_accepts() { // Stop1 delay: 12min - 10min = 2min ✓ // Stop2 delay: 24min - 20min = 4min ✓ GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(7)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(7)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), }; assertTrue( @@ -202,11 +202,11 @@ void satisfiesConstraints_passengerBeforeAllStops_checksAllStops() { // Stop1 delay: 13min - 10min = 3min ✓ // Stop2 delay: 24min - 20min = 4min ✓ GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(2)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(2)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(8)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(11)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), }; assertTrue( @@ -237,11 +237,11 @@ void satisfiesConstraints_passengerAfterAllStops_checksAllStops() { // Stop1 delay: 11min - 10min = 1min ✓ // Stop2 delay: 22min - 20min = 2min ✓ GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(11)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(11)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), }; assertTrue( @@ -271,11 +271,11 @@ void satisfiesConstraints_passengerBetweenStops_checksAllStops() { // Stop1 delay: 11min - 10min = 1min ✓ // Stop2 delay: 24min - 20min = 4min ✓ GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(7)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(11)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(7)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), }; assertTrue( @@ -296,10 +296,10 @@ void customMaxDelay_acceptsWithinCustomThreshold() { // Stop1 delayed by 8 minutes (within 10min custom threshold) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(13)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(13)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertTrue( @@ -320,10 +320,10 @@ void customMaxDelay_rejectsOverCustomThreshold() { // Stop1 delayed by 3 minutes (over 2min custom threshold) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(8)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(8)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertFalse( @@ -344,10 +344,10 @@ void customMaxDelay_zeroTolerance_rejectsAnyDelay() { // Stop1 delayed by even 1 second GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5).plusSeconds(1)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5).plusSeconds(1)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertFalse( @@ -368,10 +368,10 @@ void customMaxDelay_veryPermissive_acceptsLargeDelays() { // Stop1 delayed by 30 minutes (well within 1 hour threshold) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(35)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(35)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertTrue( @@ -412,10 +412,10 @@ void satisfiesConstraints_noDelay_accepts() { // Modified route where stop1 arrives at exactly the same time // (perfect routing somehow) GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(4)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(6)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(4)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(6)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)), }; assertTrue( @@ -444,15 +444,18 @@ void satisfiesConstraints_tripWithManyStops_checksAll() { // Insert passenger between stop2 and stop3 (positions 3, 4) // All stops should have delays <= 5 minutes // Modified indices: 0,1,2,pickup@3,dropoff@4,3,4,5,6 + // Note: With real State objects, durations will be slightly longer due to rounding + // (typically 1-3 seconds per path). We use slightly shorter durations to ensure + // the cumulative delays stay within the 5-minute threshold. GraphPath[] modifiedSegments = new GraphPath[] { - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(3)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(2)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(9)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(11)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), - MockGraphPathFactory.createMockGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(2)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(8)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), + CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)), }; assertTrue( diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java index d2ef34b9143..5b37df61e25 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java @@ -3,9 +3,9 @@ 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.ext.carpooling.CarpoolGraphPathBuilder.createGraphPaths; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; -import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.createMockGraphPaths; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithDeviationBudget; @@ -18,7 +18,7 @@ class InsertionCandidateTest { void additionalDuration_calculatesCorrectly() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // 3 segments - var segments = createMockGraphPaths(3); + var segments = createGraphPaths(3); var candidate = new InsertionCandidate( trip, @@ -35,7 +35,7 @@ void additionalDuration_calculatesCorrectly() { @Test void additionalDuration_zeroAdditional_returnsZero() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(2); + var segments = createGraphPaths(2); var candidate = new InsertionCandidate( trip, @@ -53,7 +53,7 @@ void additionalDuration_zeroAdditional_returnsZero() { @Test void isWithinDeviationBudget_withinBudget_returnsTrue() { var trip = createTripWithDeviationBudget(Duration.ofMinutes(10), OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(2); + var segments = createGraphPaths(2); var candidate = new InsertionCandidate( trip, @@ -72,7 +72,7 @@ void isWithinDeviationBudget_withinBudget_returnsTrue() { @Test void isWithinDeviationBudget_exceedsBudget_returnsFalse() { var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(2); + var segments = createGraphPaths(2); var candidate = new InsertionCandidate( trip, @@ -91,7 +91,7 @@ void isWithinDeviationBudget_exceedsBudget_returnsFalse() { @Test void isWithinDeviationBudget_exactlyAtBudget_returnsTrue() { var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(2); + var segments = createGraphPaths(2); var candidate = new InsertionCandidate( trip, @@ -109,7 +109,7 @@ void isWithinDeviationBudget_exactlyAtBudget_returnsTrue() { @Test void getPickupSegments_returnsCorrectRange() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(5); + var segments = createGraphPaths(5); var candidate = new InsertionCandidate( trip, @@ -129,7 +129,7 @@ void getPickupSegments_returnsCorrectRange() { @Test void getPickupSegments_positionZero_returnsEmpty() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(3); + var segments = createGraphPaths(3); var candidate = new InsertionCandidate( trip, @@ -147,7 +147,7 @@ void getPickupSegments_positionZero_returnsEmpty() { @Test void getSharedSegments_returnsCorrectRange() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(5); + var segments = createGraphPaths(5); var candidate = new InsertionCandidate( trip, @@ -167,7 +167,7 @@ void getSharedSegments_returnsCorrectRange() { @Test void getSharedSegments_adjacentPositions_returnsSingleSegment() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(3); + var segments = createGraphPaths(3); var candidate = new InsertionCandidate( trip, @@ -185,7 +185,7 @@ void getSharedSegments_adjacentPositions_returnsSingleSegment() { @Test void getDropoffSegments_returnsCorrectRange() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(5); + var segments = createGraphPaths(5); var candidate = new InsertionCandidate( trip, @@ -205,7 +205,7 @@ void getDropoffSegments_returnsCorrectRange() { @Test void getDropoffSegments_atEnd_returnsEmpty() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(3); + var segments = createGraphPaths(3); var candidate = new InsertionCandidate( trip, @@ -223,7 +223,7 @@ void getDropoffSegments_atEnd_returnsEmpty() { @Test void toString_includesKeyInformation() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var segments = createMockGraphPaths(3); + var segments = createGraphPaths(3); var candidate = new InsertionCandidate( trip, diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index f4901939c26..c8651a2ab7f 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.opentripplanner.ext.carpooling.CarpoolGraphPathBuilder.createGraphPath; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_MIDPOINT_NORTH; @@ -17,7 +18,6 @@ import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTHEAST; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_SOUTH; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; -import static org.opentripplanner.ext.carpooling.MockGraphPathFactory.createMockGraphPath; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStopAt; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithDeviationBudget; @@ -81,8 +81,7 @@ void findOptimalInsertion_noValidPositions_returnsNull() { void findOptimalInsertion_oneValidPosition_returnsCandidate() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - // Create mock paths BEFORE any when() statements - var mockPath = createMockGraphPath(); + var mockPath = createGraphPath(); // Mock routing to return valid paths when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); @@ -100,7 +99,7 @@ void findOptimalInsertion_routingFails_skipsPosition() { var stop1 = createStopAt(0, OSLO_EAST); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); - var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + var mockPath = createGraphPath(Duration.ofMinutes(5)); // Routing sequence: // 1. Baseline calculation (2 segments: OSLO_CENTER → OSLO_EAST → OSLO_NORTH) = mockPath x2 @@ -127,7 +126,7 @@ void findOptimalInsertion_exceedsDeviationBudget_returnsNull() { // Baseline is 2 segments * 5 min = 10 min // Modified route is 3 segments * 20 min = 60 min // Additional = 50 min, exceeds 5 min budget - var mockPath = createMockGraphPath(Duration.ofMinutes(20)); + var mockPath = createGraphPath(Duration.ofMinutes(20)); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); @@ -143,7 +142,7 @@ void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { var stop2 = createStopAt(1, OSLO_WEST); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - var mockPath = createMockGraphPath(); + var mockPath = createGraphPath(); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); } @@ -168,11 +167,11 @@ void findOptimalInsertion_selectsMinimumAdditionalDuration() { // Baseline: 1 segment (CENTER → NORTH) at 10 min // The algorithm will try multiple pickup/dropoff positions // We'll use Answer to return different durations based on segment index - var mockPath10 = createMockGraphPath(Duration.ofMinutes(10)); - var mockPath4 = createMockGraphPath(Duration.ofMinutes(4)); - var mockPath6 = createMockGraphPath(Duration.ofMinutes(6)); - var mockPath5 = createMockGraphPath(Duration.ofMinutes(5)); - var mockPath7 = createMockGraphPath(Duration.ofMinutes(7)); + var mockPath10 = createGraphPath(Duration.ofMinutes(10)); + var mockPath4 = createGraphPath(Duration.ofMinutes(4)); + var mockPath6 = createGraphPath(Duration.ofMinutes(6)); + var mockPath5 = createGraphPath(Duration.ofMinutes(5)); + var mockPath7 = createGraphPath(Duration.ofMinutes(7)); // Use thenAnswer to provide consistent route times // Just return paths with reasonable durations for all calls @@ -198,7 +197,7 @@ void findOptimalInsertion_simpleTrip_hasExpectedStructure() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Create mock paths BEFORE any when() statements - var mockPath = createMockGraphPath(); + var mockPath = createGraphPath(); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); @@ -223,16 +222,16 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { // Create mock paths with DISTINCT durations for verification // Baseline: 1 segment (CENTER → NORTH) = 10 min - var baselinePath = createMockGraphPath(Duration.ofMinutes(10)); + var baselinePath = createGraphPath(Duration.ofMinutes(10)); // Modified route segments should have DIFFERENT durations // If baseline is incorrectly reused, we'd see 10 min for A→C segment // CENTER → EAST - var segmentAC = createMockGraphPath(Duration.ofMinutes(3)); + var segmentAC = createGraphPath(Duration.ofMinutes(3)); // EAST → MIDPOINT_NORTH - var segmentCD = createMockGraphPath(Duration.ofMinutes(2)); + var segmentCD = createGraphPath(Duration.ofMinutes(2)); // MIDPOINT_NORTH → NORTH - var segmentDB = createMockGraphPath(Duration.ofMinutes(4)); + var segmentDB = createGraphPath(Duration.ofMinutes(4)); // Setup routing mock: return all segment mocks for any routing call // The algorithm will evaluate multiple insertion positions @@ -256,25 +255,29 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { // Verify the result structure assertEquals(3, result.routeSegments().size(), "Should have 3 segments in modified route"); - assertEquals(Duration.ofMinutes(10), result.baselineDuration(), "Baseline should be 10 min"); + + // Note: With real State objects, exact durations will have minor rounding differences + // (typically 1-2 seconds per edge due to millisecond rounding in StreetEdge.doTraverse()) + // The baseline should be approximately 10 minutes (within 10 seconds tolerance) + assertTrue( + Math.abs(result.baselineDuration().toSeconds() - 600) < 10, + "Baseline should be approximately 10 min (within 10s), got " + result.baselineDuration() + ); // CRITICAL: Total duration should be sum of NEW segments, NOT baseline duration - // Total = 3 + 2 + 4 = 9 minutes + // Total = 3 + 2 + 4 = 9 minutes (approximately, with rounding) // If bug exists, segment A→C would incorrectly use baseline (10 min) → total would be wrong - Duration expectedTotal = Duration.ofMinutes(9); - assertEquals( - expectedTotal, - result.totalDuration(), - "Total duration should be sum of newly routed segments" + assertTrue( + Math.abs(result.totalDuration().toSeconds() - 540) < 10, + "Total duration should be approximately 9 min (within 10s), got " + result.totalDuration() ); // Additional duration should be negative (this insertion is actually faster!) // This is realistic for insertions that "shortcut" part of the baseline route - Duration expectedAdditional = Duration.ofMinutes(-1); - assertEquals( - expectedAdditional, - result.additionalDuration(), - "Additional duration should be -1 minute (insertion is faster)" + assertTrue( + result.additionalDuration().isNegative(), + "Additional duration should be negative (insertion is faster), got " + + result.additionalDuration() ); // Verify routing was called at least 4 times (1 baseline + 3 new segments minimum) @@ -292,7 +295,7 @@ void findOptimalInsertion_insertAtEnd_reusesMostSegments() { var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); // Baseline has 2 segments: CENTER→EAST, EAST→NORTH - var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + var mockPath = createGraphPath(Duration.ofMinutes(5)); // Return mock paths for all routing calls (baseline + any new segments) when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); @@ -331,7 +334,7 @@ void findOptimalInsertion_pickupAtExistingPoint_handlesCorrectly() { var stop1 = createStopAt(0, OSLO_EAST); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTHEAST); - var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + var mockPath = createGraphPath(Duration.ofMinutes(5)); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); @@ -355,7 +358,7 @@ void findOptimalInsertion_singleSegmentTrip_routesAllNewSegments() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - var mockPath = createMockGraphPath(Duration.ofMinutes(5)); + var mockPath = createGraphPath(Duration.ofMinutes(5)); when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); From de0bf24e764077763b9d0e7963c5303d14b29d6b Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 15:41:33 +0100 Subject: [PATCH 24/40] Removes more mock usage. --- .../carpooling/filter/FilterChainTest.java | 62 +++---- .../routing/InsertionEvaluatorTest.java | 167 +++++++++++------- 2 files changed, 126 insertions(+), 103 deletions(-) diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java index 56b715e3fb5..c94ddf916f4 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java @@ -2,11 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; @@ -21,30 +16,21 @@ class FilterChainTest { @Test void accepts_allFiltersAccept_returnsTrue() { - var filter1 = mock(TripFilter.class); - var filter2 = mock(TripFilter.class); + TripFilter filter1 = (trip, pickup, dropoff) -> true; + TripFilter filter2 = (trip, pickup, dropoff) -> true; var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - when(filter1.accepts(any(), any(), any())).thenReturn(true); - when(filter2.accepts(any(), any(), any())).thenReturn(true); - var chain = new FilterChain(List.of(filter1, filter2)); assertTrue(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); - verify(filter1).accepts(trip, OSLO_EAST, OSLO_WEST); - verify(filter2).accepts(trip, OSLO_EAST, OSLO_WEST); } @Test void accepts_oneFilterRejects_returnsFalse() { - var filter1 = mock(TripFilter.class); - var filter2 = mock(TripFilter.class); + TripFilter filter1 = (trip, pickup, dropoff) -> true; + TripFilter filter2 = (trip, pickup, dropoff) -> false; var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - when(filter1.accepts(any(), any(), any())).thenReturn(true); - // Rejects - when(filter2.accepts(any(), any(), any())).thenReturn(false); - var chain = new FilterChain(List.of(filter1, filter2)); assertFalse(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); @@ -52,39 +38,37 @@ void accepts_oneFilterRejects_returnsFalse() { @Test void accepts_shortCircuits_afterFirstRejection() { - var filter1 = mock(TripFilter.class); - var filter2 = mock(TripFilter.class); - var filter3 = mock(TripFilter.class); + var filter3Called = new boolean[] { false }; + + TripFilter filter1 = (trip, pickup, dropoff) -> true; + TripFilter filter2 = (trip, pickup, dropoff) -> false; + TripFilter filter3 = (trip, pickup, dropoff) -> { + filter3Called[0] = true; + return true; + }; var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - when(filter1.accepts(any(), any(), any())).thenReturn(true); - // Rejects - when(filter2.accepts(any(), any(), any())).thenReturn(false); - // filter3 should not be called - var chain = new FilterChain(List.of(filter1, filter2, filter3)); chain.accepts(trip, OSLO_EAST, OSLO_WEST); - verify(filter1).accepts(any(), any(), any()); - verify(filter2).accepts(any(), any(), any()); - // Not called - verify(filter3, never()).accepts(any(), any(), any()); + assertFalse(filter3Called[0], "Filter3 should not have been called due to short-circuit"); } @Test void accepts_firstFilterRejects_doesNotCallOthers() { - var filter1 = mock(TripFilter.class); - var filter2 = mock(TripFilter.class); - var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + var filter2Called = new boolean[] { false }; - // First rejects - when(filter1.accepts(any(), any(), any())).thenReturn(false); + TripFilter filter1 = (trip, pickup, dropoff) -> false; + TripFilter filter2 = (trip, pickup, dropoff) -> { + filter2Called[0] = true; + return true; + }; + var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); var chain = new FilterChain(List.of(filter1, filter2)); chain.accepts(trip, OSLO_EAST, OSLO_WEST); - verify(filter1).accepts(any(), any(), any()); - verify(filter2, never()).accepts(any(), any(), any()); + assertFalse(filter2Called[0], "Filter2 should not have been called due to short-circuit"); } @Test @@ -121,13 +105,11 @@ void emptyChain_acceptsAll() { @Test void singleFilter_behavesCorrectly() { - var filter = mock(TripFilter.class); - when(filter.accepts(any(), any(), any())).thenReturn(true); + TripFilter filter = (trip, pickup, dropoff) -> true; var chain = new FilterChain(List.of(filter)); var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); assertTrue(chain.accepts(trip, OSLO_EAST, OSLO_WEST)); - verify(filter).accepts(trip, OSLO_EAST, OSLO_WEST); } } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index c8651a2ab7f..2311ee66a89 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -1,15 +1,11 @@ package org.opentripplanner.ext.carpooling.routing; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import static org.opentripplanner.ext.carpooling.CarpoolGraphPathBuilder.createGraphPath; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; @@ -27,23 +23,25 @@ import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; +import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.routing.InsertionEvaluator.RoutingFunction; import org.opentripplanner.ext.carpooling.util.BeelineEstimator; +import org.opentripplanner.framework.geometry.WgsCoordinate; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; class InsertionEvaluatorTest { - private RoutingFunction mockRoutingFunction; private PassengerDelayConstraints delayConstraints; private InsertionPositionFinder positionFinder; - private InsertionEvaluator evaluator; @BeforeEach void setup() { - mockRoutingFunction = mock(RoutingFunction.class); delayConstraints = new PassengerDelayConstraints(); positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator()); - evaluator = new InsertionEvaluator(mockRoutingFunction, delayConstraints); } /** @@ -51,9 +49,10 @@ void setup() { * This explicitly performs position finding followed by evaluation. */ private InsertionCandidate findOptimalInsertion( - org.opentripplanner.ext.carpooling.model.CarpoolTrip trip, - org.opentripplanner.framework.geometry.WgsCoordinate passengerPickup, - org.opentripplanner.framework.geometry.WgsCoordinate passengerDropoff + CarpoolTrip trip, + WgsCoordinate passengerPickup, + WgsCoordinate passengerDropoff, + RoutingFunction routingFunction ) { List viablePositions = positionFinder.findViablePositions( trip, @@ -65,14 +64,18 @@ private InsertionCandidate findOptimalInsertion( return null; } + var evaluator = new InsertionEvaluator(routingFunction, delayConstraints); return evaluator.findBestInsertion(trip, viablePositions, passengerPickup, passengerDropoff); } @Test void findOptimalInsertion_noValidPositions_returnsNull() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); + // Routing function returns null (simulating routing failure) + // This causes evaluator to skip all positions + RoutingFunction routingFunction = (from, to) -> null; - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); assertNull(result); } @@ -83,10 +86,9 @@ void findOptimalInsertion_oneValidPosition_returnsCandidate() { var mockPath = createGraphPath(); - // Mock routing to return valid paths - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + RoutingFunction routingFunction = (from, to) -> mockPath; - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); assertNotNull(result); assertEquals(1, result.pickupPosition()); @@ -105,13 +107,20 @@ void findOptimalInsertion_routingFails_skipsPosition() { // 1. Baseline calculation (2 segments: OSLO_CENTER → OSLO_EAST → OSLO_NORTH) = mockPath x2 // 2. First insertion attempt fails (null for first segment) // 3. Second insertion attempt succeeds (mockPath for all segments) - when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath, mockPath) - .thenReturn(null) - .thenReturn(mockPath, mockPath, mockPath, mockPath); + final int[] callCount = { 0 }; + RoutingFunction routingFunction = (from, to) -> { + int call = callCount[0]++; + if (call < 2) { + return mockPath; + } else if (call == 2) { + return null; + } else { + return mockPath; + } + }; // Use passenger coordinates that are compatible with trip direction (CENTER->EAST->NORTH) - var result = findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST); + var result = findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST, routingFunction); // Should skip failed routing and find a valid one assertNotNull(result); @@ -121,16 +130,15 @@ void findOptimalInsertion_routingFails_skipsPosition() { void findOptimalInsertion_exceedsDeviationBudget_returnsNull() { var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH); - // Create mock paths BEFORE any when() statements // Create routing that results in excessive additional time // Baseline is 2 segments * 5 min = 10 min // Modified route is 3 segments * 20 min = 60 min // Additional = 50 min, exceeds 5 min budget var mockPath = createGraphPath(Duration.ofMinutes(20)); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + RoutingFunction routingFunction = (from, to) -> mockPath; - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); // Should not return candidate that exceeds budget assertNull(result); @@ -144,17 +152,20 @@ void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { var mockPath = createGraphPath(); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + RoutingFunction routingFunction = (from, to) -> mockPath; + + assertDoesNotThrow(() -> + findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST, routingFunction) + ); } @Test void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - // Routing returns null (failure) for baseline calculation - when(mockRoutingFunction.route(any(), any())).thenReturn(null); + RoutingFunction routingFunction = (from, to) -> null; - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); assertNull(result); } @@ -163,27 +174,44 @@ void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() { void findOptimalInsertion_selectsMinimumAdditionalDuration() { var trip = createTripWithDeviationBudget(Duration.ofMinutes(20), OSLO_CENTER, OSLO_NORTH); - // Create mock paths BEFORE any when() statements // Baseline: 1 segment (CENTER → NORTH) at 10 min // The algorithm will try multiple pickup/dropoff positions - // We'll use Answer to return different durations based on segment index + // We'll return different durations based on segment index var mockPath10 = createGraphPath(Duration.ofMinutes(10)); var mockPath4 = createGraphPath(Duration.ofMinutes(4)); var mockPath6 = createGraphPath(Duration.ofMinutes(6)); var mockPath5 = createGraphPath(Duration.ofMinutes(5)); var mockPath7 = createGraphPath(Duration.ofMinutes(7)); - // Use thenAnswer to provide consistent route times - // Just return paths with reasonable durations for all calls + // Provide consistent route times // Baseline // First insertion (15 min total, 5 min additional) // Second insertion (18 min total, 8 min additional) - when(mockRoutingFunction.route(any(), any())) - .thenReturn(mockPath10) - .thenReturn(mockPath4, mockPath5, mockPath6) - .thenReturn(mockPath5, mockPath6, mockPath7); - - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + @SuppressWarnings("unchecked") + final GraphPath[] firstInsertionPaths = new GraphPath[] { + mockPath4, + mockPath5, + mockPath6, + }; + @SuppressWarnings("unchecked") + final GraphPath[] secondInsertionPaths = new GraphPath[] { + mockPath5, + mockPath6, + mockPath7, + }; + final int[] callCount = { 0 }; + RoutingFunction routingFunction = (from, to) -> { + int call = callCount[0]++; + if (call == 0) { + return mockPath10; + } else if (call >= 1 && call <= 3) { + return firstInsertionPaths[call - 1]; + } else { + return secondInsertionPaths[(call - 4) % 3]; + } + }; + + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); assertNotNull(result); // Should have selected one of the evaluated insertions @@ -196,12 +224,11 @@ void findOptimalInsertion_selectsMinimumAdditionalDuration() { void findOptimalInsertion_simpleTrip_hasExpectedStructure() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - // Create mock paths BEFORE any when() statements var mockPath = createGraphPath(); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + RoutingFunction routingFunction = (from, to) -> mockPath; - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); assertNotNull(result); assertNotNull(result.trip()); @@ -233,9 +260,10 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { // MIDPOINT_NORTH → NORTH var segmentDB = createGraphPath(Duration.ofMinutes(4)); - // Setup routing mock: return all segment mocks for any routing call + // Setup routing: return all segment mocks for any routing call // The algorithm will evaluate multiple insertion positions - when(mockRoutingFunction.route(any(), any())).thenReturn( + @SuppressWarnings("unchecked") + final GraphPath[] paths = new GraphPath[] { baselinePath, segmentAC, segmentCD, @@ -244,12 +272,17 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { segmentCD, segmentDB, segmentAC, - segmentCD - ); + segmentCD, + }; + final int[] callCount = { 0 }; + RoutingFunction routingFunction = (from, to) -> { + int call = callCount[0]++; + return call < paths.length ? paths[call] : segmentAC; + }; // Passenger pickup at OSLO_EAST, dropoff at OSLO_MIDPOINT_NORTH // Both are between OSLO_CENTER and OSLO_NORTH - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_MIDPOINT_NORTH); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_MIDPOINT_NORTH, routingFunction); assertNotNull(result, "Should find valid insertion"); @@ -280,9 +313,8 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { result.additionalDuration() ); - // Verify routing was called at least 4 times (1 baseline + 3 new segments minimum) - // May be more due to evaluating multiple positions - verify(mockRoutingFunction, atLeast(4)).route(any(), any()); + // Routing was called at least 4 times (1 baseline + 3 new segments minimum) + assertTrue(callCount[0] >= 4, "Should have called routing at least 4 times"); } @Test @@ -297,11 +329,14 @@ void findOptimalInsertion_insertAtEnd_reusesMostSegments() { // Baseline has 2 segments: CENTER→EAST, EAST→NORTH var mockPath = createGraphPath(Duration.ofMinutes(5)); - // Return mock paths for all routing calls (baseline + any new segments) - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + final int[] callCount = { 0 }; + RoutingFunction routingFunction = (from, to) -> { + callCount[0]++; + return mockPath; + }; // Insert passenger - the algorithm will find the best position - var result = findOptimalInsertion(trip, OSLO_WEST, OSLO_SOUTH); + var result = findOptimalInsertion(trip, OSLO_WEST, OSLO_SOUTH, routingFunction); assertNotNull(result, "Should find valid insertion"); @@ -320,10 +355,8 @@ void findOptimalInsertion_insertAtEnd_reusesMostSegments() { "Adding passenger should increase duration" ); - // Verify that routing was called for baseline and new segments - // If all segments were re-routed, we'd see many more calls - // The exact number depends on which position is optimal and how many segments can be reused - verify(mockRoutingFunction, atLeast(2)).route(any(), any()); + // Routing was called for baseline and new segments + assertTrue(callCount[0] >= 2, "Should have called routing at least 2 times"); } @Test @@ -336,11 +369,15 @@ void findOptimalInsertion_pickupAtExistingPoint_handlesCorrectly() { var mockPath = createGraphPath(Duration.ofMinutes(5)); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + final int[] callCount = { 0 }; + RoutingFunction routingFunction = (from, to) -> { + callCount[0]++; + return mockPath; + }; // Pickup exactly at OSLO_EAST (existing stop), dropoff at OSLO_NORTH (new) // OSLO_NORTH is directly on the way from OSLO_EAST to OSLO_NORTHEAST (same longitude as OSLO_EAST) - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_NORTH); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_NORTH, routingFunction); assertNotNull(result, "Should find valid insertion"); @@ -348,7 +385,7 @@ void findOptimalInsertion_pickupAtExistingPoint_handlesCorrectly() { assertTrue(result.routeSegments().size() >= 2); // Routing should be called for baseline and new segments - verify(mockRoutingFunction, atLeast(2)).route(any(), any()); + assertTrue(callCount[0] >= 2, "Should have called routing at least 2 times"); } @Test @@ -360,15 +397,19 @@ void findOptimalInsertion_singleSegmentTrip_routesAllNewSegments() { var mockPath = createGraphPath(Duration.ofMinutes(5)); - when(mockRoutingFunction.route(any(), any())).thenReturn(mockPath); + final int[] callCount = { 0 }; + RoutingFunction routingFunction = (from, to) -> { + callCount[0]++; + return mockPath; + }; - var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST); + var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); assertNotNull(result); assertEquals(3, result.routeSegments().size()); - // Verify routing was called for baseline and new segments - verify(mockRoutingFunction, atLeast(4)).route(any(), any()); + // Routing was called for baseline and new segments + assertTrue(callCount[0] >= 4, "Should have called routing at least 4 times"); // Total duration should be positive assertTrue(result.totalDuration().compareTo(Duration.ZERO) > 0); From 21b14211fb995c569706b2358e460a221072d498 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 15:44:32 +0100 Subject: [PATCH 25/40] Smaller cleanup of CarpoolSiriMapper. --- .../carpooling/updater/CarpoolSiriMapper.java | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index 1b823870e86..a97c5dfe5f4 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -75,27 +75,27 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { ); } - var boardingCall = calls.getFirst(); - var alightingCall = calls.getLast(); + var origin = calls.getFirst(); + var destination = calls.getLast(); - String tripId = journey.getEstimatedVehicleJourneyCode(); + var tripId = journey.getEstimatedVehicleJourneyCode(); - AreaStop boardingArea = buildAreaStop(boardingCall, tripId + "_boarding"); - AreaStop alightingArea = buildAreaStop(alightingCall, tripId + "_alighting"); + var originArea = buildAreaStop(origin, tripId + "_trip_origin"); + var destinationArea = buildAreaStop(destination, tripId + "_trip_destination"); - ZonedDateTime startTime = boardingCall.getExpectedDepartureTime() != null - ? boardingCall.getExpectedDepartureTime() - : boardingCall.getAimedDepartureTime(); + var startTime = origin.getExpectedDepartureTime() != null + ? origin.getExpectedDepartureTime() + : origin.getAimedDepartureTime(); // Use provided end time if available, otherwise estimate based on drive time - ZonedDateTime endTime = alightingCall.getExpectedArrivalTime() != null - ? alightingCall.getExpectedArrivalTime() - : alightingCall.getAimedArrivalTime(); + var endTime = destination.getExpectedArrivalTime() != null + ? destination.getExpectedArrivalTime() + : destination.getAimedArrivalTime(); var scheduledDuration = Duration.between(startTime, endTime); // TODO: Find a better way to exchange deviation budget with providers. - var estimatedDriveTime = calculateDriveTimeWithRouting(boardingArea, alightingArea); + var estimatedDriveTime = calculateDriveTimeWithRouting(originArea, destinationArea); var deviationBudget = scheduledDuration.minus(estimatedDriveTime); if (deviationBudget.isNegative()) { @@ -103,7 +103,7 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { deviationBudget = Duration.ofMinutes(15); } - String provider = journey.getOperatorRef().getValue(); + var provider = journey.getOperatorRef().getValue(); // Validate EstimatedCall timing order before processing validateEstimatedCallOrder(calls); @@ -111,14 +111,14 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { // Build intermediate stops from EstimatedCalls (excluding first and last) List stops = new ArrayList<>(); for (int i = 1; i < calls.size() - 1; i++) { - EstimatedCall intermediateCall = calls.get(i); - CarpoolStop stop = buildCarpoolStop(intermediateCall, tripId, i - 1); + var intermediateCall = calls.get(i); + var stop = buildCarpoolStop(intermediateCall, tripId, i - 1); stops.add(stop); } return new CarpoolTripBuilder(new FeedScopedId(FEED_ID, tripId)) - .withBoardingArea(boardingArea) - .withAlightingArea(alightingArea) + .withBoardingArea(originArea) + .withAlightingArea(destinationArea) .withStartTime(startTime) .withEndTime(endTime) .withProvider(provider) @@ -133,18 +133,18 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { * Calculate the estimated drive time between two area stops using A* routing. * Falls back to straight-line distance estimation if routing fails. * - * @param boardingArea the boarding area stop - * @param alightingArea the alighting area stop + * @param originArea the boarding area stop + * @param destinationArea the alighting area stop * @return the estimated drive time as a Duration */ - private Duration calculateDriveTimeWithRouting(AreaStop boardingArea, AreaStop alightingArea) { + private Duration calculateDriveTimeWithRouting(AreaStop originArea, AreaStop destinationArea) { try { var tempVertices = new TemporaryVerticesContainer( graph, vertexLinker, null, - GenericLocation.fromCoordinate(boardingArea.getLat(), boardingArea.getLon()), - GenericLocation.fromCoordinate(alightingArea.getLat(), alightingArea.getLon()), + GenericLocation.fromCoordinate(originArea.getLat(), originArea.getLon()), + GenericLocation.fromCoordinate(destinationArea.getLat(), destinationArea.getLon()), StreetMode.CAR, StreetMode.CAR ); @@ -161,11 +161,11 @@ private Duration calculateDriveTimeWithRouting(AreaStop boardingArea, AreaStop a return Duration.ofSeconds(durationSeconds); } else { LOG.debug("No route found between carpool stops, using straight-line estimate"); - return calculateDriveTimeFromDistance(boardingArea, alightingArea); + return calculateDriveTimeFromDistance(originArea, destinationArea); } } catch (Exception e) { LOG.error("Error calculating drive time with routing, falling back to distance estimate", e); - return calculateDriveTimeFromDistance(boardingArea, alightingArea); + return calculateDriveTimeFromDistance(originArea, destinationArea); } } From 6a220a0a5d25de2151bd9822fa501f44a151d4b1 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 5 Nov 2025 21:59:36 +0100 Subject: [PATCH 26/40] Gets timezone from transitservice for CarpoolItineraryMapper and starts cleaning up CarpoolTrip. --- .../configure/CarpoolingModule.java | 7 ++- .../internal/CarpoolItineraryMapper.java | 17 +++++- .../ext/carpooling/model/CarpoolStop.java | 56 ------------------- .../ext/carpooling/model/CarpoolTrip.java | 28 +++++----- .../carpooling/model/CarpoolTripBuilder.java | 24 ++++---- .../service/DefaultCarpoolingService.java | 7 ++- .../carpooling/updater/CarpoolSiriMapper.java | 5 +- .../carpooling/TestCarpoolTripBuilder.java | 16 +++--- 8 files changed, 62 insertions(+), 98 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index b4110cf41de..0d3f1890df5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -12,6 +12,7 @@ import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.service.StreetLimitationParametersService; +import org.opentripplanner.transit.service.TransitService; @Module public class CarpoolingModule { @@ -32,7 +33,8 @@ public static CarpoolingService provideCarpoolingService( @Nullable CarpoolingRepository repository, Graph graph, VertexLinker vertexLinker, - StreetLimitationParametersService streetLimitationParametersService + StreetLimitationParametersService streetLimitationParametersService, + TransitService transitService ) { if (OTPFeature.CarPooling.isOff()) { return null; @@ -41,7 +43,8 @@ public static CarpoolingService provideCarpoolingService( repository, graph, vertexLinker, - streetLimitationParametersService + streetLimitationParametersService, + transitService ); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java index 995118aef50..78b6c3baa0b 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java @@ -9,6 +9,7 @@ import org.opentripplanner.framework.geometry.GeometryUtils; import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.framework.model.Cost; +import org.opentripplanner.framework.time.ZoneIdFallback; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.model.plan.Place; import org.opentripplanner.routing.api.request.RouteRequest; @@ -67,6 +68,20 @@ */ public class CarpoolItineraryMapper { + private final ZoneId timeZone; + + /** + * Creates a new carpool itinerary mapper with the specified timezone. + *

    + * The timezone is used to convert passenger requested departure times from Instant to + * ZonedDateTime for comparison with driver pickup times. + *

    + * @param timeZone the timezone for time conversions, typically from TransitService.getTimeZone() + */ + public CarpoolItineraryMapper(ZoneId timeZone) { + this.timeZone = ZoneIdFallback.zoneId(timeZone); + } + /** * Converts an insertion candidate into an OTP itinerary representing the passenger's journey. *

    @@ -111,7 +126,7 @@ public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) var driverPickupTime = candidate.trip().startTime().plus(pickupDuration); var startTime = request.dateTime().isAfter(driverPickupTime.toInstant()) - ? request.dateTime().atZone(ZoneId.of("Europe/Oslo")) + ? request.dateTime().atZone(timeZone) : driverPickupTime; // Calculate shared journey duration diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java index 4da58fe0d1d..7db92de36ce 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java @@ -193,13 +193,6 @@ public AreaStop getAreaStop() { return areaStop; } - /** - * @return The type of carpool stop operation allowed - */ - public CarpoolStopType getCarpoolStopType() { - return carpoolStopType; - } - /** * @return The passenger delta at this stop. Positive values indicate pickups, * negative values indicate drop-offs @@ -215,55 +208,6 @@ public int getSequenceNumber() { return sequenceNumber; } - /** - * @return The estimated time at this stop, null if not available - */ - @Nullable - public ZonedDateTime getEstimatedTime() { - return estimatedTime; - } - - /** - * @return true if this stop allows passenger pickups - */ - public boolean allowsPickup() { - return ( - carpoolStopType == CarpoolStopType.PICKUP_ONLY || - carpoolStopType == CarpoolStopType.PICKUP_AND_DROP_OFF - ); - } - - /** - * @return true if this stop allows passenger drop-offs - */ - public boolean allowsDropOff() { - return ( - carpoolStopType == CarpoolStopType.DROP_OFF_ONLY || - carpoolStopType == CarpoolStopType.PICKUP_AND_DROP_OFF - ); - } - - /** - * @return true if passengers are being picked up at this stop (positive delta) - */ - public boolean isPickupStop() { - return passengerDelta > 0; - } - - /** - * @return true if passengers are being dropped off at this stop (negative delta) - */ - public boolean isDropOffStop() { - return passengerDelta < 0; - } - - /** - * @return the absolute number of passengers affected at this stop - */ - public int getPassengerCount() { - return Math.abs(passengerDelta); - } - @Override public boolean equals(Object obj) { if (this == obj) return true; diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index e957d2aaa5f..072ab060fc5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -22,7 +22,7 @@ * *

    Core Concepts

    *
      - *
    • Boarding/Alighting Areas: Start and end zones for the driver's journey
    • + *
    • Origin/Destination Areas: Start and end zones for the driver's journey
    • *
    • Stops: Ordered sequence of waypoints along the route where passengers * can be picked up or dropped off. Stops are dynamically updated as bookings occur.
    • *
    • Deviation Budget: Maximum additional time the driver is willing to spend @@ -59,8 +59,8 @@ public class CarpoolTrip extends AbstractTransitEntity implements LogInfo { - private final AreaStop boardingArea; - private final AreaStop alightingArea; + private final AreaStop originArea; + private final AreaStop destinationArea; private final ZonedDateTime startTime; private final ZonedDateTime endTime; private final String provider; @@ -75,8 +75,8 @@ public class CarpoolTrip public CarpoolTrip(CarpoolTripBuilder builder) { super(builder.getId()); - this.boardingArea = builder.boardingArea(); - this.alightingArea = builder.alightingArea(); + this.originArea = builder.originArea(); + this.destinationArea = builder.destinationArea(); this.startTime = builder.startTime(); this.endTime = builder.endTime(); this.provider = builder.provider(); @@ -85,12 +85,12 @@ public CarpoolTrip(CarpoolTripBuilder builder) { this.stops = Collections.unmodifiableList(builder.stops()); } - public AreaStop boardingArea() { - return boardingArea; + public AreaStop originArea() { + return originArea; } - public AreaStop alightingArea() { - return alightingArea; + public AreaStop destinationArea() { + return destinationArea; } public ZonedDateTime startTime() { @@ -128,7 +128,7 @@ public List stops() { } /** - * Builds the full list of route points including boarding area, all stops, and alighting area. + * Builds the full list of route points including origin area, all stops, and destination area. *

      * This list represents the complete path of the carpool trip, useful for distance and * direction calculations during filtering and matching. @@ -138,13 +138,13 @@ public List stops() { public List routePoints() { List points = new ArrayList<>(); - points.add(boardingArea().getCoordinate()); + points.add(originArea().getCoordinate()); for (CarpoolStop stop : stops()) { points.add(stop.getCoordinate()); } - points.add(alightingArea().getCoordinate()); + points.add(destinationArea().getCoordinate()); return points; } @@ -227,8 +227,8 @@ public String logName() { public boolean sameAs(CarpoolTrip other) { return ( getId().equals(other.getId()) && - boardingArea.equals(other.boardingArea) && - alightingArea.equals(other.alightingArea) && + originArea.equals(other.originArea) && + destinationArea.equals(other.destinationArea) && startTime.equals(other.startTime) && endTime.equals(other.endTime) && stops.equals(other.stops) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java index ab43ccf8202..5523ca7db95 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java @@ -10,8 +10,8 @@ public class CarpoolTripBuilder extends AbstractEntityBuilder { - private AreaStop boardingArea; - private AreaStop alightingArea; + private AreaStop originArea; + private AreaStop destinationArea; private ZonedDateTime startTime; private ZonedDateTime endTime; private String provider; @@ -22,8 +22,8 @@ public class CarpoolTripBuilder extends AbstractEntityBuilder stops, - WgsCoordinate alighting + WgsCoordinate destination ) { return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder( org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( @@ -85,8 +85,8 @@ public static CarpoolTrip createTripWithDeviationBudget( "trip-" + idCounter.incrementAndGet() ) ) - .withBoardingArea(createAreaStop(boarding)) - .withAlightingArea(createAreaStop(alighting)) + .withOriginArea(createAreaStop(origin)) + .withDestinationArea(createAreaStop(destination)) .withStops(stops) .withAvailableSeats(seats) .withStartTime(ZonedDateTime.now()) @@ -101,9 +101,9 @@ public static CarpoolTrip createTripWithDeviationBudget( public static CarpoolTrip createTripWithTime( ZonedDateTime startTime, int seats, - WgsCoordinate boarding, + WgsCoordinate origin, List stops, - WgsCoordinate alighting + WgsCoordinate destination ) { return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder( org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( @@ -111,8 +111,8 @@ public static CarpoolTrip createTripWithTime( "trip-" + idCounter.incrementAndGet() ) ) - .withBoardingArea(createAreaStop(boarding)) - .withAlightingArea(createAreaStop(alighting)) + .withOriginArea(createAreaStop(origin)) + .withDestinationArea(createAreaStop(destination)) .withStops(stops) .withAvailableSeats(seats) .withStartTime(startTime) From a2a9132d8b7339e8936152cfbf6e039e6b7ceb10 Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 6 Nov 2025 11:29:05 +0100 Subject: [PATCH 27/40] Moves distance calculation into shared geometry package. --- .../filter/DistanceBasedFilter.java | 79 +--------- .../geometry/SphericalDistanceLibrary.java | 58 +++++++ .../SphericalDistanceLibraryTest.java | 147 ++++++++++++++++++ 3 files changed, 213 insertions(+), 71 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java index d4078387139..2fd782d5af5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java @@ -50,15 +50,15 @@ public boolean accepts( WgsCoordinate segmentStart = routePoints.get(i); WgsCoordinate segmentEnd = routePoints.get(i + 1); - double pickupDistanceToSegment = distanceToLineSegment( - passengerPickup, - segmentStart, - segmentEnd + double pickupDistanceToSegment = SphericalDistanceLibrary.fastDistance( + passengerPickup.asJtsCoordinate(), + segmentStart.asJtsCoordinate(), + segmentEnd.asJtsCoordinate() ); - double dropoffDistanceToSegment = distanceToLineSegment( - passengerDropoff, - segmentStart, - segmentEnd + double dropoffDistanceToSegment = SphericalDistanceLibrary.fastDistance( + passengerDropoff.asJtsCoordinate(), + segmentStart.asJtsCoordinate(), + segmentEnd.asJtsCoordinate() ); // Accept if either passenger location is within threshold of this segment @@ -92,67 +92,4 @@ public boolean accepts( double getMaxDistanceMeters() { return maxDistanceMeters; } - - /** - * Calculates the distance from a point to a line segment. - *

      - * This finds the closest point on the line segment from lineStart to lineEnd, - * then calculates the spherical distance from the point to that closest point. - *

      - * The algorithm: - * 1. Projects the point onto the infinite line passing through lineStart and lineEnd - * 2. Clamps the projection to stay within the segment [lineStart, lineEnd] - * 3. Calculates the spherical distance from the point to the closest point on the segment - *

      - * Note: Uses lat/lon as if they were Cartesian coordinates for the projection - * calculation, which is an approximation. For typical carpooling distances - * (urban/suburban scale), this approximation is acceptable. - * - * @param point The point to measure from - * @param lineStart Start of the line segment - * @param lineEnd End of the line segment - * @return Distance in meters from point to the closest point on the line segment - */ - private double distanceToLineSegment( - WgsCoordinate point, - WgsCoordinate lineStart, - WgsCoordinate lineEnd - ) { - // If start and end are the same point, return distance to that point - if (lineStart.equals(lineEnd)) { - return SphericalDistanceLibrary.fastDistance( - point.asJtsCoordinate(), - lineStart.asJtsCoordinate() - ); - } - - // Calculate vector from lineStart to lineEnd - double dx = lineEnd.longitude() - lineStart.longitude(); - double dy = lineEnd.latitude() - lineStart.latitude(); - - double lineLengthSquared = dx * dx + dy * dy; - - // Calculate projection parameter t - // t represents where the projection falls on the line segment: - // t = 0 means the projection is at lineStart - // t = 1 means the projection is at lineEnd - // t between 0 and 1 means the projection is between them - double t = - ((point.longitude() - lineStart.longitude()) * dx + - (point.latitude() - lineStart.latitude()) * dy) / - lineLengthSquared; - - // Clamp t to [0, 1] to ensure we stay on the segment - t = Math.max(0, Math.min(1, t)); - - // Calculate the closest point on the segment - double closestLon = lineStart.longitude() + t * dx; - double closestLat = lineStart.latitude() + t * dy; - WgsCoordinate closestPoint = new WgsCoordinate(closestLat, closestLon); - - return SphericalDistanceLibrary.fastDistance( - point.asJtsCoordinate(), - closestPoint.asJtsCoordinate() - ); - } } diff --git a/application/src/main/java/org/opentripplanner/framework/geometry/SphericalDistanceLibrary.java b/application/src/main/java/org/opentripplanner/framework/geometry/SphericalDistanceLibrary.java index 1218c91bd34..494b8454210 100644 --- a/application/src/main/java/org/opentripplanner/framework/geometry/SphericalDistanceLibrary.java +++ b/application/src/main/java/org/opentripplanner/framework/geometry/SphericalDistanceLibrary.java @@ -65,6 +65,64 @@ public static double fastDistance(Coordinate point, LineString lineString) { return lineString2.distance(point2) * RADIUS_OF_EARTH_IN_M; } + /** + * Compute the (approximated) distance from a point to a line segment using + * Cartesian projection for the perpendicular distance calculation. + *

      + * This method projects the point onto the line segment (treating lat/lon as + * Cartesian coordinates for the projection), then calculates the spherical + * distance to the closest point on the segment. + *

      + * The algorithm: + *

        + *
      1. Projects the point onto the infinite line passing through segmentStart and segmentEnd
      2. + *
      3. Clamps the projection to stay within the segment [segmentStart, segmentEnd]
      4. + *
      5. Calculates the spherical distance from the point to the closest point on the segment
      6. + *
      + *

      + * The Cartesian approximation for the projection is acceptable for typical + * urban and suburban distances (under 50 km) where the Earth's curvature effect + * is minimal. For longer distances or higher accuracy requirements, consider + * using spherical trigonometry approaches. + * + * @param point The point to measure from (longitude, latitude degrees) + * @param segmentStart Start of the line segment (longitude, latitude degrees) + * @param segmentEnd End of the line segment (longitude, latitude degrees) + * @return The (approximated) distance, in meters, from the point to the closest + * point on the line segment + */ + public static double fastDistance( + Coordinate point, + Coordinate segmentStart, + Coordinate segmentEnd + ) { + // Handle degenerate case: segment start equals segment end + if (segmentStart.equals(segmentEnd)) { + return fastDistance(point, segmentStart); + } + + // Calculate vector from segmentStart to segmentEnd + double dx = segmentEnd.x - segmentStart.x; + double dy = segmentEnd.y - segmentStart.y; + double lineLengthSquared = dx * dx + dy * dy; + + // Calculate projection parameter t + // t represents where the projection falls on the line segment: + // t = 0 means the projection is at segmentStart + // t = 1 means the projection is at segmentEnd + // 0 < t < 1 means the projection is between them + double t = + ((point.x - segmentStart.x) * dx + (point.y - segmentStart.y) * dy) / lineLengthSquared; + + // Clamp t to [0, 1] to ensure we stay on the segment + t = Math.max(0, Math.min(1, t)); + + // Calculate the closest point on the segment + Coordinate closestPoint = new Coordinate(segmentStart.x + t * dx, segmentStart.y + t * dy); + + return fastDistance(point, closestPoint); + } + /** * Compute the length of a polyline * diff --git a/application/src/test/java/org/opentripplanner/framework/geometry/SphericalDistanceLibraryTest.java b/application/src/test/java/org/opentripplanner/framework/geometry/SphericalDistanceLibraryTest.java index 075e2616798..91cba778c2b 100644 --- a/application/src/test/java/org/opentripplanner/framework/geometry/SphericalDistanceLibraryTest.java +++ b/application/src/test/java/org/opentripplanner/framework/geometry/SphericalDistanceLibraryTest.java @@ -1,6 +1,7 @@ package org.opentripplanner.framework.geometry; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Coordinate; @@ -33,4 +34,150 @@ void testLineStringLength() { // 10 meters tolerance of this assertEquals(4 * 1852, length, 10); } + + @Test + void testFastDistance_pointToSegment_perpendicularProjection() { + // Horizontal segment at Oslo latitude + Coordinate segmentStart = new Coordinate(10.70, 59.9); + Coordinate segmentEnd = new Coordinate(10.80, 59.9); + + // Point directly north of segment midpoint + // At 59.9°N, 1° latitude ≈ 111 km + // 0.01° latitude ≈ 1.11 km ≈ 1110 meters + Coordinate point = new Coordinate(10.75, 59.91); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + + // Expected: ~1110 meters (perpendicular distance to midpoint) + // Allow 50m tolerance for approximation + assertEquals(1110, distance, 50); + } + + @Test + void testFastDistance_pointToSegment_closestPointIsStart() { + // Segment from west to east + Coordinate segmentStart = new Coordinate(10.70, 59.9); + Coordinate segmentEnd = new Coordinate(10.80, 59.9); + + // Point west of segment start + Coordinate point = new Coordinate(10.65, 59.9); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + double expectedDistance = SphericalDistanceLibrary.fastDistance(point, segmentStart); + + // Should be distance to start point (projection clamped to t=0) + assertEquals(expectedDistance, distance, 1.0); + } + + @Test + void testFastDistance_pointToSegment_closestPointIsEnd() { + // Segment from west to east + Coordinate segmentStart = new Coordinate(10.70, 59.9); + Coordinate segmentEnd = new Coordinate(10.80, 59.9); + + // Point east of segment end + Coordinate point = new Coordinate(10.85, 59.9); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + double expectedDistance = SphericalDistanceLibrary.fastDistance(point, segmentEnd); + + // Should be distance to end point (projection clamped to t=1) + assertEquals(expectedDistance, distance, 1.0); + } + + @Test + void testFastDistance_pointToSegment_pointOnSegment() { + // Segment from Oslo Center to Oslo North + Coordinate segmentStart = new Coordinate(10.7522, 59.9139); + Coordinate segmentEnd = new Coordinate(10.7922, 59.9549); + + // Point exactly on the segment (midpoint) + Coordinate point = new Coordinate( + (segmentStart.x + segmentEnd.x) / 2, + (segmentStart.y + segmentEnd.y) / 2 + ); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + + // Distance should be ~0 (within rounding error) + assertEquals(0, distance, 1.0); + } + + @Test + void testFastDistance_pointToSegment_verticalSegment() { + // Vertical segment (same longitude, different latitude) + Coordinate segmentStart = new Coordinate(10.75, 59.9); + Coordinate segmentEnd = new Coordinate(10.75, 60.0); + + // Point east of segment midpoint + Coordinate point = new Coordinate(10.76, 59.95); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + + // At 59.95°N, 0.01° longitude ≈ 560 meters + // Expected: ~560 meters perpendicular distance + assertEquals(560, distance, 50); + } + + @Test + void testFastDistance_pointToSegment_diagonalSegment() { + // Diagonal segment (northeast direction) + Coordinate segmentStart = new Coordinate(10.70, 59.90); + Coordinate segmentEnd = new Coordinate(10.80, 60.00); + + // Point southeast of segment (below the line) + Coordinate point = new Coordinate(10.75, 59.92); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + + // Should be perpendicular distance to the line + // Exact value depends on projection; verify it's reasonable + assertTrue(distance > 0, "Distance should be positive"); + assertTrue(distance < 50000, "Distance should be less than 50km for this geometry"); + } + + @Test + void testFastDistance_pointToSegment_degenerateSegment() { + // Degenerate case: segment start equals segment end + Coordinate segmentPoint = new Coordinate(10.75, 59.9); + Coordinate point = new Coordinate(10.76, 59.91); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentPoint, segmentPoint); + double expectedDistance = SphericalDistanceLibrary.fastDistance(point, segmentPoint); + + // Should fall back to point-to-point distance + assertEquals(expectedDistance, distance, 0.1); + } + + @Test + void testFastDistance_pointToSegment_veryShortSegment() { + // Very short segment (1 meter) + Coordinate segmentStart = new Coordinate(10.75000, 59.90000); + // ~1 meter east + Coordinate segmentEnd = new Coordinate(10.75001, 59.90000); + + // Point 100 meters north + Coordinate point = new Coordinate(10.75000, 59.90090); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + + // Should be approximately 100 meters (perpendicular to short segment) + assertEquals(100, distance, 10); + } + + @Test + void testFastDistance_pointToSegment_longSegment() { + // Long segment (~70 km) + Coordinate segmentStart = new Coordinate(10.70, 59.90); + Coordinate segmentEnd = new Coordinate(10.70, 60.50); + + // Point 1 km east of midpoint + Coordinate point = new Coordinate(10.71, 60.20); + + double distance = SphericalDistanceLibrary.fastDistance(point, segmentStart, segmentEnd); + + // At 60.2°N, 0.01° longitude ≈ 550 meters + // Expected: ~550 meters perpendicular distance + assertEquals(550, distance, 100); + } } From 0d8cc6f9e20fd13f429018eed666f86c9502e746 Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 6 Nov 2025 13:31:08 +0100 Subject: [PATCH 28/40] Removes routing from carpooling updater and makes it a 15 minute default for now. --- .../carpooling/updater/CarpoolSiriMapper.java | 158 +----------------- .../updater/SiriETCarpoolingUpdater.java | 10 +- .../configure/UpdaterConfigurator.java | 5 +- 3 files changed, 6 insertions(+), 167 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index 0542528f050..fc469e28f20 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -4,37 +4,17 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; -import javax.annotation.Nullable; import net.opengis.gml._3.LinearRingType; import net.opengis.gml._3.PolygonType; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LinearRing; import org.locationtech.jts.geom.Polygon; -import org.opentripplanner.astar.model.GraphPath; -import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; -import org.opentripplanner.astar.strategy.PathComparator; import org.opentripplanner.ext.carpooling.model.CarpoolStop; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder; -import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; import org.opentripplanner.framework.i18n.I18NString; -import org.opentripplanner.model.GenericLocation; -import org.opentripplanner.routing.api.request.RouteRequest; -import org.opentripplanner.routing.api.request.StreetMode; -import org.opentripplanner.routing.api.request.request.StreetRequest; -import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.routing.linking.VertexLinker; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.StreetSearchBuilder; -import org.opentripplanner.street.search.TemporaryVerticesContainer; -import org.opentripplanner.street.search.state.State; -import org.opentripplanner.street.search.strategy.DominanceFunctions; -import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; -import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.transit.model.framework.FeedScopedId; import org.opentripplanner.transit.model.site.AreaStop; import org.slf4j.Logger; @@ -48,30 +28,12 @@ public class CarpoolSiriMapper { private static final String FEED_ID = "ENT"; private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); private static final AtomicInteger COUNTER = new AtomicInteger(0); - // Average driving speed in m/s (50 km/h = ~14 m/s) - conservative estimate for urban driving - private static final double DEFAULT_DRIVING_SPEED_MS = 13.89; - // Maximum duration for route calculation (10 minutes) - private static final Duration MAX_ROUTE_DURATION = Duration.ofMinutes(10); - - private final Graph graph; - private final VertexLinker vertexLinker; - private final StreetLimitationParametersService streetLimitationParametersService; - - public CarpoolSiriMapper( - Graph graph, - VertexLinker vertexLinker, - StreetLimitationParametersService streetLimitationParametersService - ) { - this.graph = graph; - this.vertexLinker = vertexLinker; - this.streetLimitationParametersService = streetLimitationParametersService; - } public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { var calls = journey.getEstimatedCalls().getEstimatedCalls(); if (calls.size() < 2) { throw new IllegalArgumentException( - "Carpool trips must have at least 2 stops (boarding and alighting)." + "Carpool trips must have at least 2 stops (origin and destination)." ); } @@ -87,28 +49,18 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { ? origin.getExpectedDepartureTime() : origin.getAimedDepartureTime(); - // Use provided end time if available, otherwise estimate based on drive time var endTime = destination.getExpectedArrivalTime() != null ? destination.getExpectedArrivalTime() : destination.getAimedArrivalTime(); - var scheduledDuration = Duration.between(startTime, endTime); - // TODO: Find a better way to exchange deviation budget with providers. - var estimatedDriveTime = calculateDriveTimeWithRouting(originArea, destinationArea); - - var deviationBudget = scheduledDuration.minus(estimatedDriveTime); - if (deviationBudget.isNegative()) { - // Using 15 minutes as a default for now when the "time left over" method doesn't work. - deviationBudget = Duration.ofMinutes(15); - } + // Using 15 minutes as a default for now. + var deviationBudget = Duration.ofMinutes(15); var provider = journey.getOperatorRef().getValue(); - // Validate EstimatedCall timing order before processing validateEstimatedCallOrder(calls); - // Build intermediate stops from EstimatedCalls (excluding first and last) List stops = new ArrayList<>(); for (int i = 1; i < calls.size() - 1; i++) { var intermediateCall = calls.get(i); @@ -129,110 +81,6 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { .build(); } - /** - * Calculate the estimated drive time between two area stops using A* routing. - * Falls back to straight-line distance estimation if routing fails. - * - * @param originArea the boarding area stop - * @param destinationArea the alighting area stop - * @return the estimated drive time as a Duration - */ - private Duration calculateDriveTimeWithRouting(AreaStop originArea, AreaStop destinationArea) { - try { - var tempVertices = new TemporaryVerticesContainer( - graph, - vertexLinker, - null, - GenericLocation.fromCoordinate(originArea.getLat(), originArea.getLon()), - GenericLocation.fromCoordinate(destinationArea.getLat(), destinationArea.getLon()), - StreetMode.CAR, - StreetMode.CAR - ); - - // Perform A* routing - GraphPath path = performCarpoolRouting( - tempVertices.getFromVertices(), - tempVertices.getToVertices() - ); - - if (path != null) { - // Get duration from the path - long durationSeconds = path.getDuration(); - return Duration.ofSeconds(durationSeconds); - } else { - LOG.debug("No route found between carpool stops, using straight-line estimate"); - return calculateDriveTimeFromDistance(originArea, destinationArea); - } - } catch (Exception e) { - LOG.error("Error calculating drive time with routing, falling back to distance estimate", e); - return calculateDriveTimeFromDistance(originArea, destinationArea); - } - } - - /** - * Performs A* street routing between two vertices using CAR mode. - * Returns the routing result with distance, time, and geometry. - */ - @Nullable - private GraphPath performCarpoolRouting(Set from, Set to) { - try { - // Create a basic route request for car routing - RouteRequest request = RouteRequest.defaultValue(); - - // Set up street request for CAR mode - StreetRequest streetRequest = new StreetRequest(StreetMode.CAR); - - float maxCarSpeed = streetLimitationParametersService.getMaxCarSpeed(); - - var streetSearch = StreetSearchBuilder.of() - .withHeuristic(new EuclideanRemainingWeightHeuristic(maxCarSpeed)) - .withSkipEdgeStrategy(new DurationSkipEdgeStrategy<>(MAX_ROUTE_DURATION)) - .withDominanceFunction(new DominanceFunctions.MinimumWeight()) - .withRequest(request) - .withStreetRequest(streetRequest) - .withFrom(from) - .withTo(to); - - List> paths = streetSearch.getPathsToTarget(); - - if (paths.isEmpty()) { - return null; - } - - paths.sort(new PathComparator(request.arriveBy())); - return paths.getFirst(); - } catch (Exception e) { - LOG.error("Error performing carpool routing", e); - return null; - } - } - - /** - * Calculate the estimated drive time based on straight-line distance. - * Used as a fallback when A* routing is not available. - * - * @param boardingArea the boarding area stop - * @param alightingArea the alighting area stop - * @return the estimated drive time as a Duration - */ - private Duration calculateDriveTimeFromDistance(AreaStop boardingArea, AreaStop alightingArea) { - double distanceInMeters = SphericalDistanceLibrary.distance( - boardingArea.getCoordinate().asJtsCoordinate(), - alightingArea.getCoordinate().asJtsCoordinate() - ); - - // Add a buffer factor for traffic, stops, etc (30% additional time for straight-line) - double adjustedDistanceInMeters = distanceInMeters * 1.3; - - // Calculate time in seconds - double timeInSeconds = adjustedDistanceInMeters / DEFAULT_DRIVING_SPEED_MS; - - // Round up to nearest minute for more realistic estimates - long timeInMinutes = (long) Math.ceil(timeInSeconds / 60.0); - - return Duration.ofMinutes(timeInMinutes); - } - /** * Build a CarpoolStop from an EstimatedCall, using point geometry instead of area geometry. * Determines the stop type and passenger delta from the call data. diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index ce7e5c2dfab..71dbb1c1032 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -2,9 +2,6 @@ import java.util.List; import org.opentripplanner.ext.carpooling.CarpoolingRepository; -import org.opentripplanner.routing.graph.Graph; -import org.opentripplanner.routing.linking.VertexLinker; -import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.updater.spi.PollingGraphUpdater; import org.opentripplanner.updater.support.siri.SiriFileLoader; import org.opentripplanner.updater.support.siri.SiriHttpLoader; @@ -31,10 +28,7 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { public SiriETCarpoolingUpdater( SiriETCarpoolingUpdaterParameters config, - CarpoolingRepository repository, - Graph graph, - VertexLinker vertexLinker, - StreetLimitationParametersService streetLimitationParametersService + CarpoolingRepository repository ) { super(config); this.updateSource = new SiriETHttpTripUpdateSource( @@ -46,7 +40,7 @@ public SiriETCarpoolingUpdater( LOG.info("Creating SIRI-ET updater running every {}: {}", pollingPeriod(), updateSource); - this.mapper = new CarpoolSiriMapper(graph, vertexLinker, streetLimitationParametersService); + this.mapper = new CarpoolSiriMapper(); } /** diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index e6fd2de7004..2d7ccbc37ec 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -202,10 +202,7 @@ private List createUpdatersFromConfig() { updaters.add( new SiriETCarpoolingUpdater( configItem, - carpoolingRepository, - graph, - linker, - streetLimitationParametersService + carpoolingRepository ) ); } From 12c5437b25b531b9d484fc9aaedc287579201cef Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 6 Nov 2025 13:55:23 +0100 Subject: [PATCH 29/40] Adds sandbox doc for carpooling. --- doc/user/sandbox/Carpooling.md | 155 +++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 156 insertions(+) create mode 100644 doc/user/sandbox/Carpooling.md diff --git a/doc/user/sandbox/Carpooling.md b/doc/user/sandbox/Carpooling.md new file mode 100644 index 00000000000..32db178af8a --- /dev/null +++ b/doc/user/sandbox/Carpooling.md @@ -0,0 +1,155 @@ +# Carpooling + +## Contact Info + +- Entur (Norway) +- Eivind Bakke + +## Documentation + +The carpooling feature enables passengers to join existing driver journeys by being picked up and dropped off along the driver's route. The system finds optimal insertion points for new passengers while respecting capacity constraints, time windows, and route deviation budgets. + +### Configuration + +The carpooling extension is a sandbox feature that must be enabled in `otp-config.json`: + +```json +{ + "otpFeatures": { + "CarPooling": true + } +} +``` + +To enable receiving carpooling data, add the `SiriETCarpoolingUpdater` to your `router-config.json`: + +```json +{ + "updaters": [ + { + "type": "siri-et-carpooling-updater", + "feedId": "carpooling", + "url": "https://example.com/siri-et", + "frequency": "1m", + "timeout": "15s", + "requestorRef": "OTP", + "blockReadinessUntilInitialized": false, + "fuzzyTripMatching": false, + "producerMetrics": false + } + ] +} +``` + +#### Configuration Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `feedId` | `string` | | **Required**. The ID of the feed to apply the updates to. | +| `url` | `string` | | **Required**. The URL to send HTTP requests to for SIRI-ET updates. | +| `frequency` | `duration` | `1m` | How often updates should be retrieved. | +| `timeout` | `duration` | `15s` | HTTP timeout for downloading updates. | +| `requestorRef` | `string` | `null` | The requester reference sent in SIRI requests. | +| `blockReadinessUntilInitialized` | `boolean` | `false` | Whether catching up with updates should block readiness check. | +| `fuzzyTripMatching` | `boolean` | `false` | If the fuzzy trip matcher should be used to match trips. | +| `producerMetrics` | `boolean` | `false` | If failure, success, and warning metrics should be collected per producer. | + +### SIRI-ET Data Format + +The carpooling system uses SIRI-ET (Estimated Timetable) messages to receive real-time updates about carpool trips. The system maps SIRI-ET data as follows: + +- `EstimatedVehicleJourneyCode` → Trip ID +- `EstimatedCalls` → Stops on the carpooling trip. The first and the last are origin and destination stops, intermediate ones represent passenger pickup/dropoff + +The system supports multi-stop trips where drivers have already accepted multiple passengers. + +## Features + +### Trip Matching Algorithm + +The carpooling service uses a multi-phase algorithm to match passengers with compatible carpool trips: + +1. **Filter Phase** - Fast pre-screening to eliminate incompatible trips: + - **Capacity Filter**: Checks if any seats are available + - **Time-Based Filter**: Ensures departure time compatibility + - **Distance-Based Filter**: Validates pickup/dropoff are within 50km of driver's route + - **Directional Compatibility Filter**: Verifies passenger direction aligns with trip route + +2. **Routing Phase** - Optimal insertion point calculation: + - Uses beeline estimates for early rejection + - Routes baseline segments once and caches results + - Evaluates all viable insertion positions + - Selects position with minimum additional travel time + +3. **Constraint Validation**: + - **Capacity constraints**: Ensures vehicle capacity is not exceeded + - **Directional constraints**: Prevents backtracking (90° tolerance) + - **Passenger delay constraints**: Protects existing passengers (max 5 minutes additional delay) + - **Deviation budget**: Respects driver's maximum acceptable detour time + +### Multi-Stop Support + +The system handles trips with multiple existing passengers: +- Each stop tracks passenger count changes (pickups and dropoffs) +- Capacity validation ensures vehicle is never over capacity +- Route optimization considers all existing stops when inserting new passengers +- Passenger delay constraints protect all existing passengers from excessive delays + +### Integration with GraphQL API + +Carpooling results are integrated into the standard OTP GraphQL API. Carpool legs appear as a distinct leg mode (`CARPOOL`) in multi-modal itineraries, similar to how transit, walking, and biking legs are represented. + +## Architecture + +### Package Structure + +``` +org.opentripplanner.ext.carpooling/ +├── model/ # Domain models +│ ├── CarpoolTrip # Represents a carpool trip offer +│ ├── CarpoolStop # Intermediate stops with passenger delta +│ └── CarpoolLeg # Carpool segment in an itinerary +├── routing/ # Routing and insertion algorithms +│ ├── InsertionEvaluator # Finds optimal passenger insertion +│ ├── InsertionCandidate # Represents a viable insertion +│ └── CarpoolStreetRouter # Street routing for carpooling +├── filter/ # Trip pre-filtering +│ ├── TripFilter # Filter interface +│ ├── CapacityFilter # Checks available capacity +│ ├── TimeBasedFilter # Time window filtering +│ ├── DistanceBasedFilter # Geographic distance checks +│ └── DirectionalCompatibilityFilter # Directional alignment +├── constraints/ # Post-routing constraints +│ └── PassengerDelayConstraints # Protects existing passengers +├── util/ # Utilities +│ ├── BeelineEstimator # Fast travel time estimates +│ └── DirectionalCalculator # Geographic bearing calculations +├── updater/ # Real-time updates +│ ├── SiriETCarpoolingUpdater # SIRI-ET integration +│ └── CarpoolSiriMapper # Maps SIRI to domain model +└── CarpoolingService # Main service interface +``` + +## Current Limitations + +- **Static deviation budget**: We currently assume a 15 minute budget for carpooling +- **Static capacity**: Available seats are static trip properties; no reservation system +- **Basic time windows**: Only simple departure time compatibility; no "arrive by" constraints + +## Future Enhancements + +### Short Term +- Improved time window handling (including arrive by constraints) +- Add a street mode for carpooling (car_pool) for filtering carpooling searches +- Access/Egress searches for carpooling in order to integrate with transit searches +- Establish an exchange mechanism for deviation budget and occupancy + +### Medium Term +- Improved carpool stop representation +- Stable IDs for trips and stops for use in reservation +- Lookup of specific trips and stops in API, not just routing +- Support for multiple providers + +### Long Term +- Driver and passenger preference matching (eg. smoker, talker, pets, front/back seat) +- References to scheduled data (eg. areas in NeTEx) diff --git a/mkdocs.yml b/mkdocs.yml index 32611fcdd6f..5e394221e42 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - Sandbox: - About: 'SandboxExtension.md' - Actuator API: 'sandbox/ActuatorAPI.md' + - Carpooling: 'sandbox/Carpooling.md' - Debug Raster Tiles: 'sandbox/DebugRasterTiles.md' - Direct Transfer Analyzer: 'sandbox/transferanalyzer.md' - Google Cloud Storage: 'sandbox/GoogleCloudStorage.md' From eb4bdf167a55694b7edc9e70e4c3585decaf33b4 Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 6 Nov 2025 14:07:40 +0100 Subject: [PATCH 30/40] Formatting. --- .../org/opentripplanner/updater/UpdatersParameters.java | 2 +- .../updater/configure/UpdaterConfigurator.java | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java index cec70a90196..ad79014f1d6 100644 --- a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java @@ -50,6 +50,6 @@ public interface UpdatersParameters { List getSiriAzureSXUpdaterParameters(); List getSiriETCarpoolingUpdaterParameters(); - + List getMqttSiriETUpdaterParameters(); } diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index fccd3dd866c..e2f2b2a8efc 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -200,12 +200,7 @@ private List createUpdatersFromConfig() { var streetLimitationParametersService = new DefaultStreetLimitationParametersService( new StreetLimitationParameters() ); - updaters.add( - new SiriETCarpoolingUpdater( - configItem, - carpoolingRepository - ) - ); + updaters.add(new SiriETCarpoolingUpdater(configItem, carpoolingRepository)); } for (var configItem : updatersParameters.getSiriETLiteUpdaterParameters()) { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); From b7811bfb2ebdd502cb702c07f96765023553bf47 Mon Sep 17 00:00:00 2001 From: eibakke Date: Thu, 6 Nov 2025 16:09:33 +0100 Subject: [PATCH 31/40] Removes the concepts of origin and destination areas as special places for a CarpoolTrip. They really are just the first and last stop for a trip. This change also keeps all the various times for a stop location. --- .../ext/carpooling/model/CarpoolStop.java | 87 ++++++++++-- .../ext/carpooling/model/CarpoolTrip.java | 59 ++++---- .../carpooling/model/CarpoolTripBuilder.java | 22 --- .../routing/InsertionPositionFinder.java | 2 + .../carpooling/updater/CarpoolSiriMapper.java | 116 ++++++++++------ .../carpooling/TestCarpoolTripBuilder.java | 128 ++++++++++++++---- .../carpooling/filter/CapacityFilterTest.java | 19 ++- .../carpooling/filter/FilterChainTest.java | 5 +- .../model/CarpoolTripCapacityTest.java | 103 ++++++++------ .../routing/InsertionPositionFinderTest.java | 5 +- 10 files changed, 372 insertions(+), 174 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java index 7db92de36ce..d0be44f31b4 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java @@ -42,7 +42,10 @@ public enum CarpoolStopType { private final CarpoolStopType carpoolStopType; private final int passengerDelta; private final int sequenceNumber; - private final ZonedDateTime estimatedTime; + private final ZonedDateTime expectedArrivalTime; + private final ZonedDateTime aimedArrivalTime; + private final ZonedDateTime expectedDepartureTime; + private final ZonedDateTime aimedDepartureTime; /** * Creates a new CarpoolStop @@ -51,20 +54,29 @@ public enum CarpoolStopType { * @param carpoolStopType The type of operation allowed at this stop * @param passengerDelta Number of passengers picked up (positive) or dropped off (negative) * @param sequenceNumber The order of this stop in the trip (0-based) - * @param estimatedTime The estimated arrival/departure time at this stop + * @param expectedArrivalTime The expected arrival time, or null if not applicable (e.g., origin stop) + * @param aimedArrivalTime The aimed arrival time, or null if not applicable (e.g., origin stop) + * @param expectedDepartureTime The expected departure time, or null if not applicable (e.g., destination stop) + * @param aimedDepartureTime The aimed departure time, or null if not applicable (e.g., destination stop) */ public CarpoolStop( AreaStop areaStop, CarpoolStopType carpoolStopType, int passengerDelta, int sequenceNumber, - @Nullable ZonedDateTime estimatedTime + @Nullable ZonedDateTime expectedArrivalTime, + @Nullable ZonedDateTime aimedArrivalTime, + @Nullable ZonedDateTime expectedDepartureTime, + @Nullable ZonedDateTime aimedDepartureTime ) { this.areaStop = areaStop; this.carpoolStopType = carpoolStopType; this.passengerDelta = passengerDelta; this.sequenceNumber = sequenceNumber; - this.estimatedTime = estimatedTime; + this.expectedArrivalTime = expectedArrivalTime; + this.aimedArrivalTime = aimedArrivalTime; + this.expectedDepartureTime = expectedDepartureTime; + this.aimedDepartureTime = aimedDepartureTime; } // StopLocation interface implementation - delegate to the underlying AreaStop @@ -208,6 +220,56 @@ public int getSequenceNumber() { return sequenceNumber; } + /** + * @return The type of carpool operation allowed at this stop + */ + public CarpoolStopType getCarpoolStopType() { + return carpoolStopType; + } + + /** + * @return The expected arrival time, or null if not applicable (e.g., origin stop) + */ + @Nullable + public ZonedDateTime getExpectedArrivalTime() { + return expectedArrivalTime; + } + + /** + * @return The aimed arrival time, or null if not applicable (e.g., origin stop) + */ + @Nullable + public ZonedDateTime getAimedArrivalTime() { + return aimedArrivalTime; + } + + /** + * @return The expected departure time, or null if not applicable (e.g., destination stop) + */ + @Nullable + public ZonedDateTime getExpectedDepartureTime() { + return expectedDepartureTime; + } + + /** + * @return The aimed departure time, or null if not applicable (e.g., destination stop) + */ + @Nullable + public ZonedDateTime getAimedDepartureTime() { + return aimedDepartureTime; + } + + /** + * Returns the primary timing for this stop, preferring aimed arrival time. + * This provides backward compatibility for code that expects a single time value. + * + * @return The aimed arrival time if set, otherwise aimed departure time + */ + @Nullable + public ZonedDateTime getEstimatedTime() { + return aimedArrivalTime != null ? aimedArrivalTime : aimedDepartureTime; + } + @Override public boolean equals(Object obj) { if (this == obj) return true; @@ -218,7 +280,10 @@ public boolean equals(Object obj) { carpoolStopType == other.carpoolStopType && passengerDelta == other.passengerDelta && sequenceNumber == other.sequenceNumber && - java.util.Objects.equals(estimatedTime, other.estimatedTime) + java.util.Objects.equals(expectedArrivalTime, other.expectedArrivalTime) && + java.util.Objects.equals(aimedArrivalTime, other.aimedArrivalTime) && + java.util.Objects.equals(expectedDepartureTime, other.expectedDepartureTime) && + java.util.Objects.equals(aimedDepartureTime, other.aimedDepartureTime) ); } @@ -229,19 +294,25 @@ public int hashCode() { carpoolStopType, passengerDelta, sequenceNumber, - estimatedTime + expectedArrivalTime, + aimedArrivalTime, + expectedDepartureTime, + aimedDepartureTime ); } @Override public String toString() { return String.format( - "CarpoolStop{stop=%s, type=%s, delta=%d, seq=%d, time=%s}", + "CarpoolStop{stop=%s, type=%s, delta=%d, seq=%d, arr=%s/%s, dep=%s/%s}", areaStop.getId(), carpoolStopType, passengerDelta, sequenceNumber, - estimatedTime + expectedArrivalTime, + aimedArrivalTime, + expectedDepartureTime, + aimedDepartureTime ); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java index 072ab060fc5..d1e423ea53c 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java @@ -59,8 +59,6 @@ public class CarpoolTrip extends AbstractTransitEntity implements LogInfo { - private final AreaStop originArea; - private final AreaStop destinationArea; private final ZonedDateTime startTime; private final ZonedDateTime endTime; private final String provider; @@ -75,8 +73,6 @@ public class CarpoolTrip public CarpoolTrip(CarpoolTripBuilder builder) { super(builder.getId()); - this.originArea = builder.originArea(); - this.destinationArea = builder.destinationArea(); this.startTime = builder.startTime(); this.endTime = builder.endTime(); this.provider = builder.provider(); @@ -85,12 +81,30 @@ public CarpoolTrip(CarpoolTripBuilder builder) { this.stops = Collections.unmodifiableList(builder.stops()); } - public AreaStop originArea() { - return originArea; + /** + * Returns the origin stop (first stop in the trip). + * + * @return the origin stop + * @throws IllegalStateException if the trip has no stops + */ + public CarpoolStop getOrigin() { + if (stops.isEmpty()) { + throw new IllegalStateException("Trip has no stops"); + } + return stops.get(0); } - public AreaStop destinationArea() { - return destinationArea; + /** + * Returns the destination stop (last stop in the trip). + * + * @return the destination stop + * @throws IllegalStateException if the trip has no stops + */ + public CarpoolStop getDestination() { + if (stops.isEmpty()) { + throw new IllegalStateException("Trip has no stops"); + } + return stops.get(stops.size() - 1); } public ZonedDateTime startTime() { @@ -136,44 +150,33 @@ public List stops() { * @return a list of coordinates representing the full route of the trip */ public List routePoints() { - List points = new ArrayList<>(); - - points.add(originArea().getCoordinate()); - - for (CarpoolStop stop : stops()) { - points.add(stop.getCoordinate()); - } - - points.add(destinationArea().getCoordinate()); - - return points; + return stops.stream().map(CarpoolStop::getCoordinate).toList(); } /** * Calculates the number of passengers in the vehicle after visiting the specified position. *

      * Position semantics: - * - Position 0: Boarding area (before any stops) → 0 passengers + * - Position 0: Before any stops → 0 passengers * - Position N: After Nth stop → cumulative passenger delta up to stop N - * - Position stops.size() + 1: Alighting area → 0 passengers * - * @param position The position index (0 = boarding, 1 = after first stop, etc.) + * @param position The position index (0 = before any stops, 1 = after first stop, etc.) * @return Number of passengers after this position - * @throws IllegalArgumentException if position is negative or greater than stops.size() + 1 + * @throws IllegalArgumentException if position is negative or greater than stops.size() */ public int getPassengerCountAtPosition(int position) { if (position < 0) { throw new IllegalArgumentException("Position must be non-negative, got: " + position); } - if (position > stops.size() + 1) { + if (position > stops.size()) { throw new IllegalArgumentException( - "Position " + position + " exceeds valid range (0 to " + (stops.size() + 1) + ")" + "Position " + position + " exceeds valid range (0 to " + stops.size() + ")" ); } - // At the start and end of the trip there are no passengers - if (position == 0 || position == stops.size() + 1) { + // Position 0 is before any stops + if (position == 0) { return 0; } @@ -227,8 +230,6 @@ public String logName() { public boolean sameAs(CarpoolTrip other) { return ( getId().equals(other.getId()) && - originArea.equals(other.originArea) && - destinationArea.equals(other.destinationArea) && startTime.equals(other.startTime) && endTime.equals(other.endTime) && stops.equals(other.stops) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java index 5523ca7db95..915fe3d0b9f 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java @@ -10,8 +10,6 @@ public class CarpoolTripBuilder extends AbstractEntityBuilder { - private AreaStop originArea; - private AreaStop destinationArea; private ZonedDateTime startTime; private ZonedDateTime endTime; private String provider; @@ -22,8 +20,6 @@ public class CarpoolTripBuilder extends AbstractEntityBuilder findViablePositions( List viable = new ArrayList<>(); + // Pickup positions: 1 to routePoints.size()-1 (cannot pick up at position 0/origin) for (int pickupPos = 1; pickupPos < routePoints.size(); pickupPos++) { + // Dropoff positions: pickupPos+1 to routePoints.size() (can drop off up to and including destination) for (int dropoffPos = pickupPos + 1; dropoffPos <= routePoints.size(); dropoffPos++) { if (!trip.hasCapacityForInsertion(pickupPos, dropoffPos, 1)) { LOG.trace( diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index fc469e28f20..8936f0beedd 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -37,44 +37,39 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { ); } - var origin = calls.getFirst(); - var destination = calls.getLast(); - var tripId = journey.getEstimatedVehicleJourneyCode(); - var originArea = buildAreaStop(origin, tripId + "_trip_origin"); - var destinationArea = buildAreaStop(destination, tripId + "_trip_destination"); + validateEstimatedCallOrder(calls); - var startTime = origin.getExpectedDepartureTime() != null - ? origin.getExpectedDepartureTime() - : origin.getAimedDepartureTime(); + List stops = new ArrayList<>(); - var endTime = destination.getExpectedArrivalTime() != null - ? destination.getExpectedArrivalTime() - : destination.getAimedArrivalTime(); + for (int i = 0; i < calls.size(); i++) { + EstimatedCall call = calls.get(i); + boolean isFirst = (i == 0); + boolean isLast = (i == calls.size() - 1); - // TODO: Find a better way to exchange deviation budget with providers. - // Using 15 minutes as a default for now. - var deviationBudget = Duration.ofMinutes(15); + CarpoolStop stop = buildCarpoolStopForPosition(call, tripId, i, isFirst, isLast); + stops.add(stop); + } - var provider = journey.getOperatorRef().getValue(); + // Extract start/end times from first/last stops + CarpoolStop firstStop = stops.getFirst(); + CarpoolStop lastStop = stops.getLast(); - validateEstimatedCallOrder(calls); + ZonedDateTime startTime = firstStop.getExpectedDepartureTime() != null + ? firstStop.getExpectedDepartureTime() + : firstStop.getAimedDepartureTime(); - List stops = new ArrayList<>(); - for (int i = 1; i < calls.size() - 1; i++) { - var intermediateCall = calls.get(i); - var stop = buildCarpoolStop(intermediateCall, tripId, i - 1); - stops.add(stop); - } + ZonedDateTime endTime = lastStop.getExpectedArrivalTime() != null + ? lastStop.getExpectedArrivalTime() + : lastStop.getAimedArrivalTime(); return new CarpoolTripBuilder(new FeedScopedId(FEED_ID, tripId)) - .withOriginArea(originArea) - .withDestinationArea(destinationArea) .withStartTime(startTime) .withEndTime(endTime) - .withProvider(provider) - .withDeviationBudget(deviationBudget) + .withProvider(journey.getOperatorRef().getValue()) + // TODO: Find a better way to exchange deviation budget with providers. + .withDeviationBudget(Duration.ofMinutes(15)) // TODO: Make available seats dynamic based on EstimatedVehicleJourney data .withAvailableSeats(2) .withStops(stops) @@ -82,27 +77,66 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { } /** - * Build a CarpoolStop from an EstimatedCall, using point geometry instead of area geometry. - * Determines the stop type and passenger delta from the call data. + * Build a CarpoolStop from an EstimatedCall with special handling for first/last positions. * * @param call The SIRI EstimatedCall containing stop information * @param tripId The trip ID for generating unique stop IDs * @param sequenceNumber The 0-based sequence number of this stop - * @return A CarpoolStop representing the intermediate pickup/drop-off point + * @param isFirst true if this is the first stop (origin) + * @param isLast true if this is the last stop (destination) + * @return A CarpoolStop representing the stop */ - private CarpoolStop buildCarpoolStop(EstimatedCall call, String tripId, int sequenceNumber) { - var areaStop = buildAreaStop(call, tripId + "_stop_" + sequenceNumber); - - // Extract timing information - ZonedDateTime estimatedTime = call.getExpectedArrivalTime() != null - ? call.getExpectedArrivalTime() - : call.getAimedArrivalTime(); - - // Determine stop type and passenger delta from call attributes - CarpoolStop.CarpoolStopType stopType = determineCarpoolStopType(call); - int passengerDelta = calculatePassengerDelta(call, stopType); + private CarpoolStop buildCarpoolStopForPosition( + EstimatedCall call, + String tripId, + int sequenceNumber, + boolean isFirst, + boolean isLast + ) { + String stopId = isFirst + ? tripId + "_trip_origin" + : isLast ? tripId + "_trip_destination" : tripId + "_stop_" + sequenceNumber; + + var areaStop = buildAreaStop(call, stopId); + + // Extract all four timing fields + ZonedDateTime expectedArrivalTime = call.getExpectedArrivalTime(); + ZonedDateTime aimedArrivalTime = call.getAimedArrivalTime(); + ZonedDateTime expectedDepartureTime = call.getExpectedDepartureTime(); + ZonedDateTime aimedDepartureTime = call.getAimedDepartureTime(); + + // Special handling for first and last stops + CarpoolStop.CarpoolStopType stopType; + int passengerDelta; + + if (isFirst) { + // Origin: PICKUP_ONLY, no passengers initially, only departure times + stopType = CarpoolStop.CarpoolStopType.PICKUP_ONLY; + passengerDelta = 0; + expectedArrivalTime = null; + aimedArrivalTime = null; + } else if (isLast) { + // Destination: DROP_OFF_ONLY, no passengers remain, only arrival times + stopType = CarpoolStop.CarpoolStopType.DROP_OFF_ONLY; + passengerDelta = 0; + expectedDepartureTime = null; + aimedDepartureTime = null; + } else { + // Intermediate stop: determine from call data + stopType = determineCarpoolStopType(call); + passengerDelta = calculatePassengerDelta(call, stopType); + } - return new CarpoolStop(areaStop, stopType, passengerDelta, sequenceNumber, estimatedTime); + return new CarpoolStop( + areaStop, + stopType, + passengerDelta, + sequenceNumber, + expectedArrivalTime, + aimedArrivalTime, + expectedDepartureTime, + aimedDepartureTime + ); } /** diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java index 53a9d9cdaee..5c819c5ce08 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.opentripplanner.ext.carpooling.model.CarpoolStop; @@ -18,10 +19,12 @@ public class TestCarpoolTripBuilder { private static final AtomicInteger areaStopCounter = new AtomicInteger(0); /** - * Creates a simple trip with no stops and default capacity of 4. + * Creates a simple trip with origin and destination stops, default capacity of 4. */ public static CarpoolTrip createSimpleTrip(WgsCoordinate boarding, WgsCoordinate alighting) { - return createTripWithCapacity(4, boarding, List.of(), alighting); + var origin = createOriginStop(boarding); + var destination = createDestinationStop(alighting, 1); + return createTripWithCapacity(4, List.of(origin, destination)); } /** @@ -32,30 +35,53 @@ public static CarpoolTrip createSimpleTripWithTime( WgsCoordinate alighting, ZonedDateTime startTime ) { - return createTripWithTime(startTime, 4, boarding, List.of(), alighting); + var origin = createOriginStopWithTime(boarding, startTime, startTime); + var destination = createDestinationStopWithTime( + alighting, + 1, + startTime.plusHours(1), + startTime.plusHours(1) + ); + return createTripWithTime(startTime, 4, List.of(origin, destination)); } /** - * Creates a trip with specified stops. + * Creates a trip with origin, intermediate stops, and destination. */ public static CarpoolTrip createTripWithStops( WgsCoordinate boarding, - List stops, + List intermediateStops, WgsCoordinate alighting ) { - return createTripWithCapacity(4, boarding, stops, alighting); + List allStops = new ArrayList<>(); + allStops.add(createOriginStop(boarding)); + + // Renumber intermediate stops to account for origin at position 0 + for (int i = 0; i < intermediateStops.size(); i++) { + CarpoolStop intermediate = intermediateStops.get(i); + allStops.add( + new CarpoolStop( + intermediate.getAreaStop(), + intermediate.getCarpoolStopType(), + intermediate.getPassengerDelta(), + i + 1, + intermediate.getExpectedArrivalTime(), + intermediate.getAimedArrivalTime(), + intermediate.getExpectedDepartureTime(), + intermediate.getAimedDepartureTime() + ) + ); + } + + allStops.add(createDestinationStop(alighting, allStops.size())); + return createTripWithCapacity(4, allStops); } /** - * Creates a trip with specified capacity. + * Creates a trip with specified capacity and all stops (including origin/destination). */ - public static CarpoolTrip createTripWithCapacity( - int seats, - WgsCoordinate boarding, - List stops, - WgsCoordinate alighting - ) { - return createTripWithDeviationBudget(Duration.ofMinutes(10), seats, boarding, stops, alighting); + public static CarpoolTrip createTripWithCapacity(int seats, List stops) { + return createTripWithDeviationBudget(Duration.ofMinutes(10), seats, stops); } /** @@ -66,7 +92,9 @@ public static CarpoolTrip createTripWithDeviationBudget( WgsCoordinate boarding, WgsCoordinate alighting ) { - return createTripWithDeviationBudget(deviationBudget, 4, boarding, List.of(), alighting); + var origin = createOriginStop(boarding); + var destination = createDestinationStop(alighting, 1); + return createTripWithDeviationBudget(deviationBudget, 4, List.of(origin, destination)); } /** @@ -75,9 +103,7 @@ public static CarpoolTrip createTripWithDeviationBudget( public static CarpoolTrip createTripWithDeviationBudget( Duration deviationBudget, int seats, - WgsCoordinate origin, - List stops, - WgsCoordinate destination + List stops ) { return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder( org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( @@ -85,8 +111,6 @@ public static CarpoolTrip createTripWithDeviationBudget( "trip-" + idCounter.incrementAndGet() ) ) - .withOriginArea(createAreaStop(origin)) - .withDestinationArea(createAreaStop(destination)) .withStops(stops) .withAvailableSeats(seats) .withStartTime(ZonedDateTime.now()) @@ -101,9 +125,7 @@ public static CarpoolTrip createTripWithDeviationBudget( public static CarpoolTrip createTripWithTime( ZonedDateTime startTime, int seats, - WgsCoordinate origin, - List stops, - WgsCoordinate destination + List stops ) { return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder( org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable( @@ -111,8 +133,6 @@ public static CarpoolTrip createTripWithTime( "trip-" + idCounter.incrementAndGet() ) ) - .withOriginArea(createAreaStop(origin)) - .withDestinationArea(createAreaStop(destination)) .withStops(stops) .withAvailableSeats(seats) .withStartTime(startTime) @@ -144,6 +164,64 @@ public static CarpoolStop createStopAt(int sequence, int passengerDelta, WgsCoor CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF, passengerDelta, sequence, + null, + null, + null, + null + ); + } + + /** + * Creates an origin stop (first stop, PICKUP_ONLY, passengerDelta=0, departure times only). + */ + public static CarpoolStop createOriginStop(WgsCoordinate location) { + return createOriginStopWithTime(location, null, null); + } + + /** + * Creates an origin stop with specific departure times. + */ + public static CarpoolStop createOriginStopWithTime( + WgsCoordinate location, + ZonedDateTime expectedDepartureTime, + ZonedDateTime aimedDepartureTime + ) { + return new CarpoolStop( + createAreaStop(location), + CarpoolStop.CarpoolStopType.PICKUP_ONLY, + 0, + 0, + null, + null, + expectedDepartureTime, + aimedDepartureTime + ); + } + + /** + * Creates a destination stop (last stop, DROP_OFF_ONLY, passengerDelta=0, arrival times only). + */ + public static CarpoolStop createDestinationStop(WgsCoordinate location, int sequenceNumber) { + return createDestinationStopWithTime(location, sequenceNumber, null, null); + } + + /** + * Creates a destination stop with specific arrival times. + */ + public static CarpoolStop createDestinationStopWithTime( + WgsCoordinate location, + int sequenceNumber, + ZonedDateTime expectedArrivalTime, + ZonedDateTime aimedArrivalTime + ) { + return new CarpoolStop( + createAreaStop(location), + CarpoolStop.CarpoolStopType.DROP_OFF_ONLY, + 0, + sequenceNumber, + expectedArrivalTime, + aimedArrivalTime, + null, null ); } diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java index 08a22df4420..5b899bebf30 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java @@ -7,8 +7,11 @@ import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_SOUTH; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createDestinationStop; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createOriginStop; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStop; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -25,7 +28,7 @@ void setup() { @Test void accepts_tripWithCapacity_returnsTrue() { - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(), OSLO_NORTH); assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); } @@ -36,7 +39,7 @@ void accepts_tripAtFullCapacity_returnsTrue() { // Detailed capacity validation happens in the validator layer // All 4 seats taken var stop1 = createStop(0, 4); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); // Filter accepts because trip has capacity configured (even if currently full) assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); @@ -46,14 +49,15 @@ void accepts_tripAtFullCapacity_returnsTrue() { void accepts_tripWithOneOpenSeat_returnsTrue() { // 3 of 4 seats taken var stop1 = createStop(0, 3); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); } @Test void accepts_zeroCapacityTrip_returnsFalse() { - var trip = createTripWithCapacity(0, OSLO_CENTER, List.of(), OSLO_NORTH); + var stops = List.of(createOriginStop(OSLO_CENTER), createDestinationStop(OSLO_NORTH, 1)); + var trip = createTripWithCapacity(0, stops); assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); } @@ -61,7 +65,8 @@ void accepts_zeroCapacityTrip_returnsFalse() { @Test void accepts_passengerCoordinatesIgnored() { // Filter only checks if ANY capacity exists, not position-specific - var trip = createTripWithCapacity(2, OSLO_CENTER, List.of(), OSLO_NORTH); + var stops = List.of(createOriginStop(OSLO_CENTER), createDestinationStop(OSLO_NORTH, 1)); + var trip = createTripWithCapacity(2, stops); // Should accept regardless of passenger coordinates assertTrue(filter.accepts(trip, OSLO_SOUTH, OSLO_EAST)); @@ -76,7 +81,7 @@ void accepts_tripWithFluctuatingCapacity_checksOverallAvailability() { var stop2 = createStop(1, -2); // Pickup 1 var stop3 = createStop(2, 1); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); // At some point there's capacity (positions 0, 2+) assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST)); @@ -91,7 +96,7 @@ void accepts_tripAlwaysAtCapacity_returnsTrue() { var stop2 = createStop(1, -1); // Pick 1 (back to full) var stop3 = createStop(2, 1); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); // Filter accepts because trip has capacity configured // The validator will determine if there's actual room for insertion diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java index c94ddf916f4..0b63cfef26f 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java @@ -6,6 +6,8 @@ import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_EAST; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createDestinationStop; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createOriginStop; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; @@ -77,7 +79,8 @@ void standard_includesAllStandardFilters() { // Should contain CapacityFilter and DirectionalCompatibilityFilter // Verify by testing behavior with a trip that has no capacity - var emptyTrip = createTripWithCapacity(0, OSLO_CENTER, List.of(), OSLO_NORTH); + var stops = List.of(createOriginStop(OSLO_CENTER), createDestinationStop(OSLO_NORTH, 1)); + var emptyTrip = createTripWithCapacity(0, stops); // Should reject due to capacity filter assertFalse(chain.accepts(emptyTrip, OSLO_EAST, OSLO_WEST)); diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java index a83a9eb98ae..803f98e4b26 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java @@ -31,16 +31,21 @@ void getPassengerCountAtPosition_noStops_allZeros() { @Test void getPassengerCountAtPosition_onePickupStop_incrementsAtStop() { - // Pickup 1 passenger + // Pickup 1 passenger, then drop off 1 passenger var stop1 = createStop(0, 1); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var stop2 = createStop(1, -1); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - // Before stop + // Position 0: Before origin stop assertEquals(0, trip.getPassengerCountAtPosition(0)); - // After stop - assertEquals(1, trip.getPassengerCountAtPosition(1)); - // Alighting - assertEquals(0, trip.getPassengerCountAtPosition(2)); + // Position 1: After origin stop (passengerDelta=0) + assertEquals(0, trip.getPassengerCountAtPosition(1)); + // Position 2: After pickup stop (passengerDelta=1) + assertEquals(1, trip.getPassengerCountAtPosition(2)); + // Position 3: After dropoff stop (passengerDelta=-1) + assertEquals(0, trip.getPassengerCountAtPosition(3)); + // Position 4: After destination stop (passengerDelta=0) + assertEquals(0, trip.getPassengerCountAtPosition(4)); } @Test @@ -49,16 +54,22 @@ void getPassengerCountAtPosition_pickupAndDropoff_incrementsThenDecrements() { var stop1 = createStop(0, 2); // Dropoff 1 passenger var stop2 = createStop(1, -1); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + // Dropoff remaining passenger + var stop3 = createStop(2, -1); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH); - // Before any stops + // Position 0: Before origin stop assertEquals(0, trip.getPassengerCountAtPosition(0)); - // After first pickup - assertEquals(2, trip.getPassengerCountAtPosition(1)); - // After dropoff - assertEquals(1, trip.getPassengerCountAtPosition(2)); - // Alighting - assertEquals(0, trip.getPassengerCountAtPosition(3)); + // Position 1: After origin stop (passengerDelta=0) + assertEquals(0, trip.getPassengerCountAtPosition(1)); + // Position 2: After first intermediate stop (passengerDelta=2) + assertEquals(2, trip.getPassengerCountAtPosition(2)); + // Position 3: After second intermediate stop (passengerDelta=-1) + assertEquals(1, trip.getPassengerCountAtPosition(3)); + // Position 4: After third intermediate stop (passengerDelta=-1) + assertEquals(0, trip.getPassengerCountAtPosition(4)); + // Position 5: After destination stop (passengerDelta=0) + assertEquals(0, trip.getPassengerCountAtPosition(5)); } @Test @@ -67,19 +78,29 @@ void getPassengerCountAtPosition_multipleStops_cumulativeCount() { var stop2 = createStop(1, 2); var stop3 = createStop(2, -1); var stop4 = createStop(3, 1); - var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3, stop4), OSLO_NORTH); - + var stop5 = createStop(4, -3); + var trip = createTripWithStops( + OSLO_CENTER, + List.of(stop1, stop2, stop3, stop4, stop5), + OSLO_NORTH + ); + + // Position 0: Before origin assertEquals(0, trip.getPassengerCountAtPosition(0)); - // 0 + 1 - assertEquals(1, trip.getPassengerCountAtPosition(1)); - // 1 + 2 - assertEquals(3, trip.getPassengerCountAtPosition(2)); - // 3 - 1 - assertEquals(2, trip.getPassengerCountAtPosition(3)); - // 2 + 1 - assertEquals(3, trip.getPassengerCountAtPosition(4)); - // Alighting - assertEquals(0, trip.getPassengerCountAtPosition(5)); + // Position 1: After origin (passengerDelta=0) + assertEquals(0, trip.getPassengerCountAtPosition(1)); + // Position 2: After stop1 (0 + 1) + assertEquals(1, trip.getPassengerCountAtPosition(2)); + // Position 3: After stop2 (1 + 2) + assertEquals(3, trip.getPassengerCountAtPosition(3)); + // Position 4: After stop3 (3 - 1) + assertEquals(2, trip.getPassengerCountAtPosition(4)); + // Position 5: After stop4 (2 + 1) + assertEquals(3, trip.getPassengerCountAtPosition(5)); + // Position 6: After stop5 (3 - 3) + assertEquals(0, trip.getPassengerCountAtPosition(6)); + // Position 7: After destination (passengerDelta=0) + assertEquals(0, trip.getPassengerCountAtPosition(7)); } @Test @@ -95,16 +116,17 @@ void getPassengerCountAtPosition_positionTooLarge_throwsException() { var stop2 = createStop(1, 1); var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - // Valid positions are 0 to 3 (stops.size() + 1) - // Position 4 should throw - assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(4)); + // Trip has: origin (0), stop1 (1), stop2 (2), destination (3) = 4 stops total + // Valid positions are 0 to 4 (0 to stops.size()) + // Position 5 should throw + assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(5)); // Position 999 should also throw assertThrows(IllegalArgumentException.class, () -> trip.getPassengerCountAtPosition(999)); } @Test void hasCapacityForInsertion_noPassengers_hasCapacity() { - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(), OSLO_NORTH); assertTrue(trip.hasCapacityForInsertion(1, 2, 1)); // Can fit all 4 seats @@ -115,7 +137,7 @@ void hasCapacityForInsertion_noPassengers_hasCapacity() { void hasCapacityForInsertion_fullCapacity_noCapacity() { // Fill all 4 seats var stop1 = createStop(0, 4); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); // No room for additional passenger after stop 1 assertFalse(trip.hasCapacityForInsertion(2, 3, 1)); @@ -125,7 +147,7 @@ void hasCapacityForInsertion_fullCapacity_noCapacity() { void hasCapacityForInsertion_partialCapacity_hasCapacityForOne() { // 3 of 4 seats taken var stop1 = createStop(0, 3); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); // Room for 1 assertTrue(trip.hasCapacityForInsertion(2, 3, 1)); @@ -136,20 +158,21 @@ void hasCapacityForInsertion_partialCapacity_hasCapacityForOne() { @Test void hasCapacityForInsertion_acrossMultiplePositions_checksAll() { var stop1 = createStop(0, 2); - // Total 3 passengers at position 2 + // Total 3 passengers at position 3 var stop2 = createStop(1, 1); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); - // Range 1-3 includes position with 3 passengers, so only 1 seat available - assertTrue(trip.hasCapacityForInsertion(1, 3, 1)); - assertFalse(trip.hasCapacityForInsertion(1, 3, 2)); + // Trip positions: 0 (before origin), 1 (after origin=0), 2 (after stop1=2), 3 (after stop2=3), 4 (after dest=0) + // Range 2-4 includes position 3 with 3 passengers, so only 1 seat available + assertTrue(trip.hasCapacityForInsertion(2, 4, 1)); + assertFalse(trip.hasCapacityForInsertion(2, 4, 2)); } @Test void hasCapacityForInsertion_rangeBeforeStop_usesInitialCapacity() { // Fill capacity at position 1 var stop1 = createStop(0, 4); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH); // Pickup at position 1, dropoff at position 1 - only checks capacity at boarding (position 0) // At boarding there are no passengers yet, so we have full capacity @@ -162,7 +185,7 @@ void hasCapacityForInsertion_capacityFreesUpInRange_checksMaxInRange() { var stop1 = createStop(0, 3); // 2 dropoff, leaving 1 var stop2 = createStop(1, -2); - var trip = createTripWithCapacity(4, OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); + var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH); // Range includes both positions - max passengers is 3 (at position 1) // 4 total - 3 max = 1 available diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java index acd83d64277..1f30d33faf2 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java @@ -8,6 +8,8 @@ import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_SOUTH; import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_WEST; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createDestinationStop; +import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createOriginStop; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createSimpleTrip; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createStopAt; import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithCapacity; @@ -60,7 +62,8 @@ void findViablePositions_incompatibleDirection_rejectsPosition() { @Test void findViablePositions_noCapacity_rejectsPosition() { // Create a trip with 0 available seats - var trip = createTripWithCapacity(0, OSLO_CENTER, List.of(), OSLO_NORTH); + var stops = List.of(createOriginStop(OSLO_CENTER), createDestinationStop(OSLO_NORTH, 1)); + var trip = createTripWithCapacity(0, stops); var viablePositions = finder.findViablePositions(trip, OSLO_EAST, OSLO_WEST); From 83bb9f5cf491b4535182f48031868708c9d42d5f Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 7 Nov 2025 15:28:12 +0100 Subject: [PATCH 32/40] Addresses comments in PR. --- .../ext/carpooling/Architecture.md | 89 +++++++++++++++++++ .../ext/carpooling/model/CarpoolStop.java | 12 --- .../ext/carpooling/model/CarpoolStopType.java | 13 +++ .../routing/InsertionEvaluator.java | 12 +-- .../carpooling/routing/InsertionPosition.java | 38 +++++--- .../carpooling/routing/RoutingFunction.java | 15 ++++ .../service/DefaultCarpoolingService.java | 15 ++-- .../carpooling/updater/CarpoolSiriMapper.java | 30 ++++--- .../carpooling/TestCarpoolTripBuilder.java | 7 +- .../routing/InsertionEvaluatorTest.java | 1 - doc/user/sandbox/Carpooling.md | 75 +++++----------- 11 files changed, 194 insertions(+), 113 deletions(-) create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/Architecture.md create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStopType.java create mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/Architecture.md b/application/src/ext/java/org/opentripplanner/ext/carpooling/Architecture.md new file mode 100644 index 00000000000..7ffa3b64ee7 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/Architecture.md @@ -0,0 +1,89 @@ +# Carpooling Extension Architecture + +## Overview + +The carpooling extension enables passengers to join existing driver journeys by being picked up and dropped off along the driver's route. The system finds optimal insertion points for new passengers while respecting capacity constraints, time windows, and route deviation budgets. + +## Package Structure + +``` +org.opentripplanner.ext.carpooling/ +├── model/ # Domain models +│ ├── CarpoolTrip # Represents a carpool trip offer +│ ├── CarpoolStop # Intermediate stops with passenger delta +│ └── CarpoolLeg # Carpool segment in an itinerary +├── routing/ # Routing and insertion algorithms +│ ├── InsertionEvaluator # Finds optimal passenger insertion +│ ├── InsertionCandidate # Represents a viable insertion +│ └── CarpoolStreetRouter # Street routing for carpooling +├── filter/ # Trip pre-filtering +│ ├── TripFilter # Filter interface +│ ├── CapacityFilter # Checks available capacity +│ ├── TimeBasedFilter # Time window filtering +│ ├── DistanceBasedFilter # Geographic distance checks +│ └── DirectionalCompatibilityFilter # Directional alignment +├── constraints/ # Post-routing constraints +│ └── PassengerDelayConstraints # Protects existing passengers +├── util/ # Utilities +│ ├── BeelineEstimator # Fast travel time estimates +│ └── DirectionalCalculator # Geographic bearing calculations +├── updater/ # Real-time updates +│ ├── SiriETCarpoolingUpdater # SIRI-ET integration +│ └── CarpoolSiriMapper # Maps SIRI to domain model +└── service/ # Service layer + ├── CarpoolingService # Main service interface + └── DefaultCarpoolingService # Service implementation +``` + +## Trip Matching Algorithm + +The carpooling service uses a multi-phase algorithm to match passengers with compatible carpool trips: + +### 1. Filter Phase +Fast pre-screening to eliminate incompatible trips: +- **Capacity Filter**: Checks if any seats are available +- **Time-Based Filter**: Ensures departure time compatibility +- **Distance-Based Filter**: Validates pickup/dropoff are within 50km of driver's route +- **Directional Compatibility Filter**: Verifies passenger direction aligns with trip route + +### 2. Routing Phase +Optimal insertion point calculation: +- Uses beeline estimates for early rejection +- Routes baseline segments once and caches results +- Evaluates all viable insertion positions +- Selects position with minimum additional travel time + +### 3. Constraint Validation +- **Capacity constraints**: Ensures vehicle capacity is not exceeded +- **Directional constraints**: Prevents backtracking (90° tolerance) +- **Passenger delay constraints**: Protects existing passengers (max 5 minutes additional delay) +- **Deviation budget**: Respects driver's maximum acceptable detour time + +## Multi-Stop Support + +The system handles trips with multiple existing passengers: +- Each stop tracks passenger count changes (pickups and dropoffs) +- Capacity validation ensures vehicle is never over capacity +- Route optimization considers all existing stops when inserting new passengers +- Passenger delay constraints protect all existing passengers from excessive delays + +## Integration Points + +### GraphQL API +Carpooling results are integrated into the standard OTP GraphQL API. Carpool legs appear as a distinct leg mode (`CARPOOL`) in multi-modal itineraries, similar to how transit, walking, and biking legs are represented. + +### SIRI-ET Updater +The `SiriETCarpoolingUpdater` receives real-time updates about carpool trips via SIRI-ET (Estimated Timetable) messages. The `CarpoolSiriMapper` maps SIRI-ET data to the internal domain model: +- `EstimatedVehicleJourneyCode` → Trip ID +- `EstimatedCalls` → Stops on the carpooling trip + +## Design Decisions + +### Static Deviation Budget +Currently assumes a 15 minute budget for carpooling. Future versions will support configurable or dynamically negotiated deviation budgets. + +### Static Capacity +Available seats are static trip properties. There is no reservation system yet. + +### Basic Time Windows +Only simple departure time compatibility is implemented. "Arrive by" constraints are planned for future versions. diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java index d0be44f31b4..b1e70994de9 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java @@ -26,18 +26,6 @@ */ public class CarpoolStop implements StopLocation { - /** - * The type of carpool stop operation - */ - public enum CarpoolStopType { - /** Only passengers can be picked up at this stop */ - PICKUP_ONLY, - /** Only passengers can be dropped off at this stop */ - DROP_OFF_ONLY, - /** Both pickup and drop-off are allowed */ - PICKUP_AND_DROP_OFF, - } - private final AreaStop areaStop; private final CarpoolStopType carpoolStopType; private final int passengerDelta; diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStopType.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStopType.java new file mode 100644 index 00000000000..2235fd78280 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStopType.java @@ -0,0 +1,13 @@ +package org.opentripplanner.ext.carpooling.model; + +/** + * The type of carpool stop operation. + */ +public enum CarpoolStopType { + /** Only passengers can be picked up at this stop */ + PICKUP_ONLY, + /** Only passengers can be dropped off at this stop */ + DROP_OFF_ONLY, + /** Both pickup and drop-off are allowed */ + PICKUP_AND_DROP_OFF, +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java index c88cd630317..2c7889f38fe 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java @@ -32,6 +32,8 @@ public class InsertionEvaluator { private static final Logger LOG = LoggerFactory.getLogger(InsertionEvaluator.class); + private static final Duration INITIAL_ADDITIONAL_DURATION = Duration.ofDays(1); + private final RoutingFunction routingFunction; private final PassengerDelayConstraints delayConstraints; @@ -109,7 +111,7 @@ public InsertionCandidate findBestInsertion( Duration[] cumulativeDurations = calculateCumulativeDurations(baselineSegments); InsertionCandidate bestCandidate = null; - Duration minAdditionalDuration = Duration.ofDays(1); + Duration minAdditionalDuration = INITIAL_ADDITIONAL_DURATION; Duration baselineDuration = cumulativeDurations[cumulativeDurations.length - 1]; for (InsertionPosition position : viablePositions) { @@ -332,12 +334,4 @@ private int getBaselineSegmentIndex( ); return -1; } - - /** - * Functional interface for street routing. - */ - @FunctionalInterface - public interface RoutingFunction { - GraphPath route(GenericLocation from, GenericLocation to); - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java index 67932e49bc2..2471e39599a 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java @@ -1,22 +1,36 @@ package org.opentripplanner.ext.carpooling.routing; -/** - * Represents a pickup and dropoff position pair that passed heuristic validation. - *

      - * This is an intermediate value used between finding viable positions (via heuristics) - * and evaluating them (via A* routing). Positions are 1-indexed to match the insertion - * point semantics in the route modification algorithm. - * - * @param pickupPos Position to insert passenger pickup (1-indexed) - * @param dropoffPos Position to insert passenger dropoff (1-indexed, always > pickupPos) - */ -public record InsertionPosition(int pickupPos, int dropoffPos) { - public InsertionPosition { +public class InsertionPosition { + + private final int pickupPos; + private final int dropOffPos; + + /** + * Represents a pickup and dropoff position pair that passed heuristic validation. + *

      + * This is an intermediate value used between finding viable positions (via heuristics) + * and evaluating them (via A* routing). Positions are 1-indexed to match the insertion + * point semantics in the route modification algorithm. + * + * @param pickupPos Position to insert passenger pickup (1-indexed) + * @param dropoffPos Position to insert passenger dropoff (1-indexed, always > pickupPos) + */ + public InsertionPosition(int pickupPos, int dropoffPos) { if (dropoffPos <= pickupPos) { throw new IllegalArgumentException( "dropoffPos (%d) must be greater than pickupPos (%d)".formatted(dropoffPos, pickupPos) ); } + this.pickupPos = pickupPos; + this.dropOffPos = dropoffPos; + } + + public int pickupPos() { + return pickupPos; + } + + public int dropoffPos() { + return dropOffPos; } /** diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java new file mode 100644 index 00000000000..a36fdcd2258 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java @@ -0,0 +1,15 @@ +package org.opentripplanner.ext.carpooling.routing; + +import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.vertex.Vertex; +import org.opentripplanner.street.search.state.State; + +/** + * Functional interface for street routing. + */ +@FunctionalInterface +public interface RoutingFunction { + GraphPath route(GenericLocation from, GenericLocation to); +} diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index 828a2520999..0bca057e16f 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -72,6 +72,7 @@ public class DefaultCarpoolingService implements CarpoolingService { private static final Logger LOG = LoggerFactory.getLogger(DefaultCarpoolingService.class); private static final int DEFAULT_MAX_CARPOOL_RESULTS = 3; + private static final Duration DEFAULT_SEARCH_WINDOW = Duration.ofMinutes(30); private final CarpoolingRepository repository; private final Graph graph; @@ -121,7 +122,7 @@ public List route(RouteRequest request) throws RoutingValidationExcep WgsCoordinate passengerDropoff = new WgsCoordinate(request.to().getCoordinate()); var passengerDepartureTime = request.dateTime(); var searchWindow = request.searchWindow() == null - ? Duration.ofMinutes(30) + ? DEFAULT_SEARCH_WINDOW : request.searchWindow(); LOG.debug( @@ -213,18 +214,14 @@ public List route(RouteRequest request) throws RoutingValidationExcep } private void validateRequest(RouteRequest request) throws RoutingValidationException { - if ( - Objects.requireNonNull(request.from()).lat == null || - Objects.requireNonNull(request.from()).lng == null - ) { + Objects.requireNonNull(request.from()); + Objects.requireNonNull(request.to()); + if (request.from().lat == null || request.from() == null) { throw new RoutingValidationException( List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.FROM_PLACE)) ); } - if ( - Objects.requireNonNull(request.to()).lat == null || - Objects.requireNonNull(request.to()).lng == null - ) { + if (request.to().lat == null || request.to().lng == null) { throw new RoutingValidationException( List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.TO_PLACE)) ); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java index 8936f0beedd..46c97c848a5 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java @@ -12,6 +12,7 @@ import org.locationtech.jts.geom.LinearRing; import org.locationtech.jts.geom.Polygon; import org.opentripplanner.ext.carpooling.model.CarpoolStop; +import org.opentripplanner.ext.carpooling.model.CarpoolStopType; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder; import org.opentripplanner.framework.i18n.I18NString; @@ -29,6 +30,9 @@ public class CarpoolSiriMapper { private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); private static final AtomicInteger COUNTER = new AtomicInteger(0); + private static final int DEFAULT_AVAILABLE_SEATS = 2; + private static final Duration DEFAULT_DEVIATION_BUDGET = Duration.ofMinutes(15); + public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { var calls = journey.getEstimatedCalls().getEstimatedCalls(); if (calls.size() < 2) { @@ -69,9 +73,9 @@ public CarpoolTrip mapSiriToCarpoolTrip(EstimatedVehicleJourney journey) { .withEndTime(endTime) .withProvider(journey.getOperatorRef().getValue()) // TODO: Find a better way to exchange deviation budget with providers. - .withDeviationBudget(Duration.ofMinutes(15)) + .withDeviationBudget(DEFAULT_DEVIATION_BUDGET) // TODO: Make available seats dynamic based on EstimatedVehicleJourney data - .withAvailableSeats(2) + .withAvailableSeats(DEFAULT_AVAILABLE_SEATS) .withStops(stops) .build(); } @@ -106,18 +110,18 @@ private CarpoolStop buildCarpoolStopForPosition( ZonedDateTime aimedDepartureTime = call.getAimedDepartureTime(); // Special handling for first and last stops - CarpoolStop.CarpoolStopType stopType; + CarpoolStopType stopType; int passengerDelta; if (isFirst) { // Origin: PICKUP_ONLY, no passengers initially, only departure times - stopType = CarpoolStop.CarpoolStopType.PICKUP_ONLY; + stopType = CarpoolStopType.PICKUP_ONLY; passengerDelta = 0; expectedArrivalTime = null; aimedArrivalTime = null; } else if (isLast) { // Destination: DROP_OFF_ONLY, no passengers remain, only arrival times - stopType = CarpoolStop.CarpoolStopType.DROP_OFF_ONLY; + stopType = CarpoolStopType.DROP_OFF_ONLY; passengerDelta = 0; expectedDepartureTime = null; aimedDepartureTime = null; @@ -142,35 +146,35 @@ private CarpoolStop buildCarpoolStopForPosition( /** * Determine the carpool stop type from the EstimatedCall data. */ - private CarpoolStop.CarpoolStopType determineCarpoolStopType(EstimatedCall call) { + private CarpoolStopType determineCarpoolStopType(EstimatedCall call) { boolean hasArrival = call.getExpectedArrivalTime() != null || call.getAimedArrivalTime() != null; boolean hasDeparture = call.getExpectedDepartureTime() != null || call.getAimedDepartureTime() != null; if (hasArrival && hasDeparture) { - return CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF; + return CarpoolStopType.PICKUP_AND_DROP_OFF; } else if (hasDeparture) { - return CarpoolStop.CarpoolStopType.PICKUP_ONLY; + return CarpoolStopType.PICKUP_ONLY; } else if (hasArrival) { - return CarpoolStop.CarpoolStopType.DROP_OFF_ONLY; + return CarpoolStopType.DROP_OFF_ONLY; } else { - return CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF; + return CarpoolStopType.PICKUP_AND_DROP_OFF; } } /** * Calculate the passenger delta (change in passenger count) from the EstimatedCall. */ - private int calculatePassengerDelta(EstimatedCall call, CarpoolStop.CarpoolStopType stopType) { + private int calculatePassengerDelta(EstimatedCall call, CarpoolStopType stopType) { // This is a placeholder implementation - adapt based on SIRI ET data structure // SIRI ET may have passenger count changes, boarding/alighting numbers, etc. // For now, return a default value of 1 passenger pickup/dropoff - if (stopType == CarpoolStop.CarpoolStopType.DROP_OFF_ONLY) { + if (stopType == CarpoolStopType.DROP_OFF_ONLY) { // Assume 1 passenger drop-off return -1; - } else if (stopType == CarpoolStop.CarpoolStopType.PICKUP_ONLY) { + } else if (stopType == CarpoolStopType.PICKUP_ONLY) { // Assume 1 passenger pickup return 1; } else { diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java index 5c819c5ce08..4b95add5c69 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.opentripplanner.ext.carpooling.model.CarpoolStop; +import org.opentripplanner.ext.carpooling.model.CarpoolStopType; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.transit.model.site.AreaStop; @@ -161,7 +162,7 @@ public static CarpoolStop createStopAt(int sequence, WgsCoordinate location) { public static CarpoolStop createStopAt(int sequence, int passengerDelta, WgsCoordinate location) { return new CarpoolStop( createAreaStop(location), - CarpoolStop.CarpoolStopType.PICKUP_AND_DROP_OFF, + CarpoolStopType.PICKUP_AND_DROP_OFF, passengerDelta, sequence, null, @@ -188,7 +189,7 @@ public static CarpoolStop createOriginStopWithTime( ) { return new CarpoolStop( createAreaStop(location), - CarpoolStop.CarpoolStopType.PICKUP_ONLY, + CarpoolStopType.PICKUP_ONLY, 0, 0, null, @@ -216,7 +217,7 @@ public static CarpoolStop createDestinationStopWithTime( ) { return new CarpoolStop( createAreaStop(location), - CarpoolStop.CarpoolStopType.DROP_OFF_ONLY, + CarpoolStopType.DROP_OFF_ONLY, 0, sequenceNumber, expectedArrivalTime, diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index 2311ee66a89..3a0c68e5a91 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -26,7 +26,6 @@ import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.ext.carpooling.constraints.PassengerDelayConstraints; import org.opentripplanner.ext.carpooling.model.CarpoolTrip; -import org.opentripplanner.ext.carpooling.routing.InsertionEvaluator.RoutingFunction; import org.opentripplanner.ext.carpooling.util.BeelineEstimator; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.street.model.edge.Edge; diff --git a/doc/user/sandbox/Carpooling.md b/doc/user/sandbox/Carpooling.md index 32db178af8a..1098f98d5f0 100644 --- a/doc/user/sandbox/Carpooling.md +++ b/doc/user/sandbox/Carpooling.md @@ -65,70 +65,37 @@ The system supports multi-stop trips where drivers have already accepted multipl ## Features -### Trip Matching Algorithm +### Trip Matching -The carpooling service uses a multi-phase algorithm to match passengers with compatible carpool trips: +The carpooling service matches passengers with compatible carpool trips based on several criteria: -1. **Filter Phase** - Fast pre-screening to eliminate incompatible trips: - - **Capacity Filter**: Checks if any seats are available - - **Time-Based Filter**: Ensures departure time compatibility - - **Distance-Based Filter**: Validates pickup/dropoff are within 50km of driver's route - - **Directional Compatibility Filter**: Verifies passenger direction aligns with trip route +- **Availability**: Checks if seats are available in the vehicle +- **Time Compatibility**: Ensures the trip timing works for the passenger +- **Route Alignment**: Validates that pickup and dropoff locations are reasonably close to the driver's route +- **Direction**: Verifies the passenger's travel direction aligns with the trip route -2. **Routing Phase** - Optimal insertion point calculation: - - Uses beeline estimates for early rejection - - Routes baseline segments once and caches results - - Evaluates all viable insertion positions - - Selects position with minimum additional travel time +The system automatically calculates the optimal pickup and dropoff points along the driver's route that minimize additional travel time while respecting all constraints. -3. **Constraint Validation**: - - **Capacity constraints**: Ensures vehicle capacity is not exceeded - - **Directional constraints**: Prevents backtracking (90° tolerance) - - **Passenger delay constraints**: Protects existing passengers (max 5 minutes additional delay) - - **Deviation budget**: Respects driver's maximum acceptable detour time +### Constraints and Protections -### Multi-Stop Support +To ensure a good experience for all users, the system enforces several constraints: -The system handles trips with multiple existing passengers: -- Each stop tracks passenger count changes (pickups and dropoffs) -- Capacity validation ensures vehicle is never over capacity -- Route optimization considers all existing stops when inserting new passengers -- Passenger delay constraints protect all existing passengers from excessive delays +- **Vehicle Capacity**: Never exceeds the maximum number of seats +- **Route Logic**: Prevents backtracking or illogical detours +- **Existing Passenger Protection**: Limits additional delay to existing passengers (maximum 5 minutes) +- **Driver Deviation Budget**: Respects the driver's maximum acceptable detour time (currently 15 minutes) -### Integration with GraphQL API +### Multi-Stop Trips -Carpooling results are integrated into the standard OTP GraphQL API. Carpool legs appear as a distinct leg mode (`CARPOOL`) in multi-modal itineraries, similar to how transit, walking, and biking legs are represented. +The system supports trips where drivers have already accepted multiple passengers. When matching a new passenger to such a trip, the system: +- Considers all existing pickup and dropoff points +- Ensures the vehicle capacity is never exceeded at any point in the trip +- Protects all existing passengers from excessive delays +- Finds the optimal insertion point for the new passenger -## Architecture +### API Integration -### Package Structure - -``` -org.opentripplanner.ext.carpooling/ -├── model/ # Domain models -│ ├── CarpoolTrip # Represents a carpool trip offer -│ ├── CarpoolStop # Intermediate stops with passenger delta -│ └── CarpoolLeg # Carpool segment in an itinerary -├── routing/ # Routing and insertion algorithms -│ ├── InsertionEvaluator # Finds optimal passenger insertion -│ ├── InsertionCandidate # Represents a viable insertion -│ └── CarpoolStreetRouter # Street routing for carpooling -├── filter/ # Trip pre-filtering -│ ├── TripFilter # Filter interface -│ ├── CapacityFilter # Checks available capacity -│ ├── TimeBasedFilter # Time window filtering -│ ├── DistanceBasedFilter # Geographic distance checks -│ └── DirectionalCompatibilityFilter # Directional alignment -├── constraints/ # Post-routing constraints -│ └── PassengerDelayConstraints # Protects existing passengers -├── util/ # Utilities -│ ├── BeelineEstimator # Fast travel time estimates -│ └── DirectionalCalculator # Geographic bearing calculations -├── updater/ # Real-time updates -│ ├── SiriETCarpoolingUpdater # SIRI-ET integration -│ └── CarpoolSiriMapper # Maps SIRI to domain model -└── CarpoolingService # Main service interface -``` +Carpooling results are available through the standard OTP GraphQL API. Carpool legs appear as a distinct mode (`CARPOOL`) in multi-modal itineraries, alongside transit, walking, and biking legs. ## Current Limitations From 41db441db0bf005e9f6f6142620d7182cb9c0b0a Mon Sep 17 00:00:00 2001 From: eibakke Date: Fri, 7 Nov 2025 15:49:00 +0100 Subject: [PATCH 33/40] Regenerates the Configuration.md --- doc/user/Configuration.md | 73 ++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/doc/user/Configuration.md b/doc/user/Configuration.md index e1da2613d06..2e884174bcd 100644 --- a/doc/user/Configuration.md +++ b/doc/user/Configuration.md @@ -222,42 +222,43 @@ Here is a list of all features which can be toggled on/off and their default val -| Feature | Description | Enabled by default | Sandbox | -|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------:|:-------:| -| `AlertMetrics` | Starts a background thread to continuously publish metrics about alerts. Needs to be enabled together with `ActuatorAPI`. | | | -| `APIServerInfo` | Enable the server info endpoint. | ✓️ | | -| `APIUpdaterStatus` | Enable endpoint for graph updaters status. | ✓️ | | -| `IncludeEmptyRailStopsInTransfers` | Turning this on guarantees that Rail stops without scheduled departures still get included when generating transfers using `ConsiderPatternsForDirectTransfers`. It is common for stops to be assign at real-time for Rail. Turning this on will help to avoid dropping transfers which are needed, when the stop is in use later. Turning this on, if ConsiderPatternsForDirectTransfers is off has no effect. | | | -| `ConsiderPatternsForDirectTransfers` | Enable limiting transfers so that there is only a single transfer to each pattern. | ✓️ | | -| `DebugUi` | Enable the debug GraphQL client and web UI and located at the root of the web server as well as the debug map tiles it uses. Be aware that the map tiles are not a stable API and can change without notice. Use the [vector tiles feature](sandbox/MapboxVectorTilesApi.md) if you want a stable map tiles API. | ✓️ | | -| `ExtraTransferLegOnSameStop` | Should there be a transfer leg when transferring on the very same stop. Note that for in-seat/interlined transfers no transfer leg will be generated. | | | -| `FloatingBike` | Enable floating bike routing. | ✓️ | | -| `GtfsGraphQlApi` | Enable the [GTFS GraphQL API](apis/GTFS-GraphQL-API.md). | ✓️ | | -| `MinimumTransferTimeIsDefinitive` | If the minimum transfer time is a lower bound (default) or the definitive time for the transfer. Set this to `true` if you want to set a transfer time lower than what OTP derives from OSM data. | | | -| `OptimizeTransfers` | OTP will inspect all itineraries found and optimize where (which stops) the transfer will happen. Waiting time, priority and guaranteed transfers are taken into account. | ✓️ | | -| `ParallelRouting` | Enable performing parts of the trip planning in parallel. | | | -| `TransferConstraints` | Enforce transfers to happen according to the _transfers.txt_ (GTFS) and Interchanges (NeTEx). Turning this _off_ will increase the routing performance a little. | ✓️ | | -| `TransmodelGraphQlApi` | Enable the [Transmodel (NeTEx) GraphQL API](apis/TransmodelApi.md). | ✓️ | ✓️ | -| `ActuatorAPI` | Endpoint for actuators (service health status). | | ✓️ | -| `AsyncGraphQLFetchers` | Whether the @async annotation in the GraphQL schema should lead to the fetch being executed asynchronously. This allows batch or alias queries to run in parallel at the cost of consuming extra threads. | | | -| `WaitForGraphUpdateInPollingUpdaters` | Make all polling updaters wait for graph updates to complete before finishing. If this is not enabled, the updaters will finish after submitting the task to update the graph. | ✓️ | | -| `CarPooling` | Enable the carpooling sandbox module. | | ✓️ | -| `Emission` | Enable the emission sandbox module. | | ✓️ | -| `EmpiricalDelay` | Enable empirical delay sandbox module. | | ✓️ | -| `DataOverlay` | Enable usage of data overlay when calculating costs for the street network. | | ✓️ | -| `DebugRasterTiles` | Enable debug raster tile API. | | ✓️ | -| `FaresV2` | Enable import of GTFS-Fares v2 data. | | ✓️ | -| `FlexRouting` | Enable FLEX routing. | | ✓️ | -| `GoogleCloudStorage` | Enable Google Cloud Storage integration. | | ✓️ | -| `MultiCriteriaGroupMaxFilter` | Keep the best itinerary with respect to each criteria used in the transit-routing search. For example the itinerary with the lowest cost, fewest transfers, and each unique transit-group (transit-group-priority) is kept, even if the max-limit is exceeded. This is turned off by default for now, until this feature is well tested. | | | -| `RealtimeResolver` | When routing with ignoreRealtimeUpdates=true, add an extra step which populates results with real-time data | | ✓️ | -| `ReportApi` | Enable the report API. | | ✓️ | -| `SandboxAPIGeocoder` | Enable the Geocoder API. | | ✓️ | -| `SandboxAPIMapboxVectorTilesApi` | Enable Mapbox vector tiles API. | | ✓️ | -| `SandboxAPIParkAndRideApi` | Enable park-and-ride endpoint. | | ✓️ | -| `Sorlandsbanen` | Include train Sørlandsbanen in results when searching in south of Norway. Only relevant in Norway. | | ✓️ | -| `TransferAnalyzer` | Analyze transfers during graph build. | | ✓️ | -| `TriasApi` | TRIAS API. | | ✓️ | +| Feature | Description | Enabled by default | Sandbox | +|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------:|:-------:| +| `AlertMetrics` | Starts a background thread to continuously publish metrics about alerts. Needs to be enabled together with `ActuatorAPI`. | | | +| `APIServerInfo` | Enable the server info endpoint. | ✓️ | | +| `APIUpdaterStatus` | Enable endpoint for graph updaters status. | ✓️ | | +| `IncludeEmptyRailStopsInTransfers` | Turning this on guarantees that Rail stops without scheduled departures still get included when generating transfers using `ConsiderPatternsForDirectTransfers`. It is common for stops to be assign at real-time for Rail. Turning this on will help to avoid dropping transfers which are needed, when the stop is in use later. Turning this on, if ConsiderPatternsForDirectTransfers is off has no effect. | | | +| `ConsiderPatternsForDirectTransfers` | Enable limiting transfers so that there is only a single transfer to each pattern. | ✓️ | | +| `DebugUi` | Enable the debug GraphQL client and web UI and located at the root of the web server as well as the debug map tiles it uses. Be aware that the map tiles are not a stable API and can change without notice. Use the [vector tiles feature](sandbox/MapboxVectorTilesApi.md) if you want a stable map tiles API. | ✓️ | | +| `ExtraTransferLegOnSameStop` | Should there be a transfer leg when transferring on the very same stop. Note that for in-seat/interlined transfers no transfer leg will be generated. | | | +| `FloatingBike` | Enable floating bike routing. | ✓️ | | +| `GtfsGraphQlApi` | Enable the [GTFS GraphQL API](apis/GTFS-GraphQL-API.md). | ✓️ | | +| `MinimumTransferTimeIsDefinitive` | If the minimum transfer time is a lower bound (default) or the definitive time for the transfer. Set this to `true` if you want to set a transfer time lower than what OTP derives from OSM data. | | | +| `OnDemandRaptorTransfer` | Calculate transfers only when accessed by Raptor, instead of calculating and caching all transfers for the whole graph, for runtime requests which are not pre-cached in `transferCacheRequests` in router-config.json. This may help performance when doing local journey planning in a large graph. Requests which are specified in `transferCacheRequests` in router-config.json are not affected and are always pre-cached for the whole graph. | | | +| `OptimizeTransfers` | OTP will inspect all itineraries found and optimize where (which stops) the transfer will happen. Waiting time, priority and guaranteed transfers are taken into account. | ✓️ | | +| `ParallelRouting` | Enable performing parts of the trip planning in parallel. | | | +| `TransferConstraints` | Enforce transfers to happen according to the _transfers.txt_ (GTFS) and Interchanges (NeTEx). Turning this _off_ will increase the routing performance a little. | ✓️ | | +| `TransmodelGraphQlApi` | Enable the [Transmodel (NeTEx) GraphQL API](apis/TransmodelApi.md). | ✓️ | ✓️ | +| `ActuatorAPI` | Endpoint for actuators (service health status). | | ✓️ | +| `AsyncGraphQLFetchers` | Whether the @async annotation in the GraphQL schema should lead to the fetch being executed asynchronously. This allows batch or alias queries to run in parallel at the cost of consuming extra threads. | | | +| `WaitForGraphUpdateInPollingUpdaters` | Make all polling updaters wait for graph updates to complete before finishing. If this is not enabled, the updaters will finish after submitting the task to update the graph. | ✓️ | | +| `CarPooling` | Enable the carpooling sandbox module. | | ✓️ | +| `Emission` | Enable the emission sandbox module. | | ✓️ | +| `EmpiricalDelay` | Enable empirical delay sandbox module. | | ✓️ | +| `DataOverlay` | Enable usage of data overlay when calculating costs for the street network. | | ✓️ | +| `DebugRasterTiles` | Enable debug raster tile API. | | ✓️ | +| `FaresV2` | Enable import of GTFS-Fares v2 data. | | ✓️ | +| `FlexRouting` | Enable FLEX routing. | | ✓️ | +| `GoogleCloudStorage` | Enable Google Cloud Storage integration. | | ✓️ | +| `MultiCriteriaGroupMaxFilter` | Keep the best itinerary with respect to each criteria used in the transit-routing search. For example the itinerary with the lowest cost, fewest transfers, and each unique transit-group (transit-group-priority) is kept, even if the max-limit is exceeded. This is turned off by default for now, until this feature is well tested. | | | +| `RealtimeResolver` | When routing with ignoreRealtimeUpdates=true, add an extra step which populates results with real-time data | | ✓️ | +| `ReportApi` | Enable the report API. | | ✓️ | +| `SandboxAPIGeocoder` | Enable the Geocoder API. | | ✓️ | +| `SandboxAPIMapboxVectorTilesApi` | Enable Mapbox vector tiles API. | | ✓️ | +| `SandboxAPIParkAndRideApi` | Enable park-and-ride endpoint. | | ✓️ | +| `Sorlandsbanen` | Include train Sørlandsbanen in results when searching in south of Norway. Only relevant in Norway. | | ✓️ | +| `TransferAnalyzer` | Analyze transfers during graph build. | | ✓️ | +| `TriasApi` | TRIAS API. | | ✓️ | From 0f2f94f602b2d49a480c6e37a3235a6d9c6d880e Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 17 Nov 2025 13:55:05 +0100 Subject: [PATCH 34/40] Removes unnecessary sourceParameters method form SiriETLiteUpdaterParameters and SiriETUpdaterParameters. --- .../opentripplanner/updater/configure/SiriUpdaterModule.java | 4 ++-- .../updater/trip/siri/updater/SiriETUpdaterParameters.java | 3 --- .../trip/siri/updater/lite/SiriETLiteUpdaterParameters.java | 3 --- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java index bbe8057e1e7..1b4d4a7f4a6 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java @@ -41,11 +41,11 @@ public static SiriSXUpdater createSiriSXUpdater( private static EstimatedTimetableSource createSource(SiriETUpdater.Parameters params) { return switch (params) { case SiriETUpdaterParameters p -> new SiriETHttpTripUpdateSource( - p.sourceParameters(), + p, createLoader(params) ); case SiriETLiteUpdaterParameters p -> new SiriETLiteHttpTripUpdateSource( - p.sourceParameters(), + p, createLoader(params) ); default -> throw new IllegalArgumentException("Unexpected value: " + params); diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java index d4eb5630de8..00a48d31e83 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java @@ -17,7 +17,4 @@ public record SiriETUpdaterParameters( boolean producerMetrics ) implements SiriETUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters { - public SiriETHttpTripUpdateSource.Parameters sourceParameters() { - return this; - } } diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java index 8ab33c66ca9..c280907fba2 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java @@ -15,9 +15,6 @@ public record SiriETLiteUpdaterParameters( HttpHeaders httpRequestHeaders ) implements SiriETUpdater.Parameters, SiriETLiteHttpTripUpdateSource.Parameters { - public SiriETLiteHttpTripUpdateSource.Parameters sourceParameters() { - return this; - } @Override public String url() { From 70ff308346cea51b188b5ec49b1334afaecd5d6c Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 17 Nov 2025 13:57:48 +0100 Subject: [PATCH 35/40] Renames SiriETUpdaterParameters to DefaultSiriETUpdaterParameters. --- .../standalone/config/routerconfig/UpdatersConfig.java | 4 ++-- .../config/routerconfig/updaters/SiriETUpdaterConfig.java | 6 +++--- .../org/opentripplanner/updater/UpdatersParameters.java | 4 ++-- .../updater/configure/SiriUpdaterModule.java | 8 ++++---- ...arameters.java => DefaultSiriETUpdaterParameters.java} | 5 ++--- .../siri/updater/lite/SiriETLiteUpdaterParameters.java | 1 - 6 files changed, 13 insertions(+), 15 deletions(-) rename application/src/main/java/org/opentripplanner/updater/trip/siri/updater/{SiriETUpdaterParameters.java => DefaultSiriETUpdaterParameters.java} (88%) diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java index 0e3280edcde..c42b6892cf3 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java @@ -54,7 +54,7 @@ import org.opentripplanner.updater.alert.siri.lite.SiriSXLiteUpdaterParameters; import org.opentripplanner.updater.trip.gtfs.updater.http.PollingTripUpdaterParameters; import org.opentripplanner.updater.trip.gtfs.updater.mqtt.MqttGtfsRealtimeUpdaterParameters; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; +import org.opentripplanner.updater.trip.siri.updater.DefaultSiriETUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.google.SiriETGooglePubsubUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.lite.SiriETLiteUpdaterParameters; import org.opentripplanner.updater.vehicle_parking.VehicleParkingUpdaterParameters; @@ -178,7 +178,7 @@ public List getVehiclePositionsUpdaterParamet } @Override - public List getSiriETUpdaterParameters() { + public List getSiriETUpdaterParameters() { return getParameters(SIRI_ET_UPDATER); } diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETUpdaterConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETUpdaterConfig.java index 219aaa13401..6116059a69a 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETUpdaterConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETUpdaterConfig.java @@ -6,12 +6,12 @@ import java.time.Duration; import org.opentripplanner.standalone.config.framework.json.NodeAdapter; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; +import org.opentripplanner.updater.trip.siri.updater.DefaultSiriETUpdaterParameters; public class SiriETUpdaterConfig { - public static SiriETUpdaterParameters create(String configRef, NodeAdapter c) { - return new SiriETUpdaterParameters( + public static DefaultSiriETUpdaterParameters create(String configRef, NodeAdapter c) { + return new DefaultSiriETUpdaterParameters( configRef, c.of("feedId").since(V2_0).summary("The ID of the feed to apply the updates to.").asString(), c diff --git a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java index ad79014f1d6..c12c8693ad6 100644 --- a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java @@ -11,7 +11,7 @@ import org.opentripplanner.updater.alert.siri.lite.SiriSXLiteUpdaterParameters; import org.opentripplanner.updater.trip.gtfs.updater.http.PollingTripUpdaterParameters; import org.opentripplanner.updater.trip.gtfs.updater.mqtt.MqttGtfsRealtimeUpdaterParameters; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; +import org.opentripplanner.updater.trip.siri.updater.DefaultSiriETUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.google.SiriETGooglePubsubUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.lite.SiriETLiteUpdaterParameters; import org.opentripplanner.updater.vehicle_parking.VehicleParkingUpdaterParameters; @@ -31,7 +31,7 @@ public interface UpdatersParameters { List getVehiclePositionsUpdaterParameters(); - List getSiriETUpdaterParameters(); + List getSiriETUpdaterParameters(); List getSiriETGooglePubsubUpdaterParameters(); diff --git a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java index 1b4d4a7f4a6..5cb94ddd7a3 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java @@ -12,10 +12,10 @@ import org.opentripplanner.updater.support.siri.SiriLoader; import org.opentripplanner.updater.trip.metrics.TripUpdateMetrics; import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter; +import org.opentripplanner.updater.trip.siri.updater.DefaultSiriETUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; import org.opentripplanner.updater.trip.siri.updater.SiriETUpdater; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.lite.SiriETLiteHttpTripUpdateSource; import org.opentripplanner.updater.trip.siri.updater.lite.SiriETLiteUpdaterParameters; @@ -40,7 +40,7 @@ public static SiriSXUpdater createSiriSXUpdater( private static EstimatedTimetableSource createSource(SiriETUpdater.Parameters params) { return switch (params) { - case SiriETUpdaterParameters p -> new SiriETHttpTripUpdateSource( + case DefaultSiriETUpdaterParameters p -> new SiriETHttpTripUpdateSource( p, createLoader(params) ); @@ -81,7 +81,7 @@ private static SiriLoader createLoader(SiriETUpdater.Parameters params) { // Fallback to default loader else { return switch (params) { - case SiriETUpdaterParameters p -> new SiriHttpLoader( + case DefaultSiriETUpdaterParameters p -> new SiriHttpLoader( p.url(), p.timeout(), p.httpRequestHeaders(), @@ -99,7 +99,7 @@ private static SiriLoader createLoader(SiriETUpdater.Parameters params) { private static Consumer createMetricsConsumer(SiriETUpdater.Parameters params) { return switch (params) { - case SiriETUpdaterParameters p -> TripUpdateMetrics.streaming(p); + case DefaultSiriETUpdaterParameters p -> TripUpdateMetrics.streaming(p); case SiriETLiteUpdaterParameters p -> TripUpdateMetrics.batch(p); default -> throw new IllegalArgumentException("Unexpected value: " + params); }; diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java similarity index 88% rename from application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java rename to application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java index 00a48d31e83..37021d8974d 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java @@ -3,7 +3,7 @@ import java.time.Duration; import org.opentripplanner.updater.spi.HttpHeaders; -public record SiriETUpdaterParameters( +public record DefaultSiriETUpdaterParameters( String configRef, String feedId, boolean blockReadinessUntilInitialized, @@ -16,5 +16,4 @@ public record SiriETUpdaterParameters( HttpHeaders httpRequestHeaders, boolean producerMetrics ) - implements SiriETUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters { -} + implements SiriETUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters {} diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java index c280907fba2..37c4f61f9c2 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java @@ -15,7 +15,6 @@ public record SiriETLiteUpdaterParameters( HttpHeaders httpRequestHeaders ) implements SiriETUpdater.Parameters, SiriETLiteHttpTripUpdateSource.Parameters { - @Override public String url() { return uri.toString(); From bde81afa338da79e7c0e048275e19038a8185221 Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 17 Nov 2025 13:59:25 +0100 Subject: [PATCH 36/40] Extracts SiriETUpdaterParameters into its own file. --- .../updater/configure/SiriUpdaterModule.java | 9 +++++---- .../siri/updater/DefaultSiriETUpdaterParameters.java | 2 +- .../updater/trip/siri/updater/SiriETUpdater.java | 12 +----------- .../trip/siri/updater/SiriETUpdaterParameters.java | 12 ++++++++++++ .../updater/lite/SiriETLiteUpdaterParameters.java | 4 ++-- 5 files changed, 21 insertions(+), 18 deletions(-) create mode 100644 application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java diff --git a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java index 5cb94ddd7a3..e7b0dee65fb 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java @@ -16,6 +16,7 @@ import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; import org.opentripplanner.updater.trip.siri.updater.SiriETUpdater; +import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.lite.SiriETLiteHttpTripUpdateSource; import org.opentripplanner.updater.trip.siri.updater.lite.SiriETLiteUpdaterParameters; @@ -25,7 +26,7 @@ public class SiriUpdaterModule { public static SiriETUpdater createSiriETUpdater( - SiriETUpdater.Parameters params, + SiriETUpdaterParameters params, SiriRealTimeTripUpdateAdapter adapter ) { return new SiriETUpdater(params, adapter, createSource(params), createMetricsConsumer(params)); @@ -38,7 +39,7 @@ public static SiriSXUpdater createSiriSXUpdater( return new SiriSXUpdater(params, timetableRepository, createLoader(params)); } - private static EstimatedTimetableSource createSource(SiriETUpdater.Parameters params) { + private static EstimatedTimetableSource createSource(SiriETUpdaterParameters params) { return switch (params) { case DefaultSiriETUpdaterParameters p -> new SiriETHttpTripUpdateSource( p, @@ -73,7 +74,7 @@ private static SiriLoader createLoader(SiriSXUpdater.Parameters params) { }; } - private static SiriLoader createLoader(SiriETUpdater.Parameters params) { + private static SiriLoader createLoader(SiriETUpdaterParameters params) { // Load real-time updates from a file. if (SiriFileLoader.matchesUrl(params.url())) { return new SiriFileLoader(params.url()); @@ -97,7 +98,7 @@ private static SiriLoader createLoader(SiriETUpdater.Parameters params) { } } - private static Consumer createMetricsConsumer(SiriETUpdater.Parameters params) { + private static Consumer createMetricsConsumer(SiriETUpdaterParameters params) { return switch (params) { case DefaultSiriETUpdaterParameters p -> TripUpdateMetrics.streaming(p); case SiriETLiteUpdaterParameters p -> TripUpdateMetrics.batch(p); diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java index 37021d8974d..2dad44d3d78 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java @@ -16,4 +16,4 @@ public record DefaultSiriETUpdaterParameters( HttpHeaders httpRequestHeaders, boolean producerMetrics ) - implements SiriETUpdater.Parameters, SiriETHttpTripUpdateSource.Parameters {} + implements SiriETUpdaterParameters, SiriETHttpTripUpdateSource.Parameters {} diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdater.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdater.java index 4920bae70a5..c096b6c4f69 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdater.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdater.java @@ -3,10 +3,8 @@ import java.util.List; import java.util.function.Consumer; import org.opentripplanner.updater.spi.PollingGraphUpdater; -import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; import org.opentripplanner.updater.spi.ResultLogger; import org.opentripplanner.updater.spi.UpdateResult; -import org.opentripplanner.updater.trip.UrlUpdaterParameters; import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter; import org.opentripplanner.utils.tostring.ToStringBuilder; import org.slf4j.Logger; @@ -35,7 +33,7 @@ public class SiriETUpdater extends PollingGraphUpdater { private final Consumer metricsConsumer; public SiriETUpdater( - Parameters config, + SiriETUpdaterParameters config, SiriRealTimeTripUpdateAdapter adapter, EstimatedTimetableSource source, Consumer metricsConsumer @@ -97,12 +95,4 @@ public String toString() { .addDuration("frequency", pollingPeriod()) .toString(); } - - public interface Parameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { - String url(); - - boolean blockReadinessUntilInitialized(); - - boolean fuzzyTripMatching(); - } } diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java new file mode 100644 index 00000000000..ecee36be770 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java @@ -0,0 +1,12 @@ +package org.opentripplanner.updater.trip.siri.updater; + +import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; +import org.opentripplanner.updater.trip.UrlUpdaterParameters; + +public interface SiriETUpdaterParameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { + String url(); + + boolean blockReadinessUntilInitialized(); + + boolean fuzzyTripMatching(); +} diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java index 37c4f61f9c2..fa49f79bae8 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/lite/SiriETLiteUpdaterParameters.java @@ -3,7 +3,7 @@ import java.net.URI; import java.time.Duration; import org.opentripplanner.updater.spi.HttpHeaders; -import org.opentripplanner.updater.trip.siri.updater.SiriETUpdater; +import org.opentripplanner.updater.trip.siri.updater.SiriETUpdaterParameters; public record SiriETLiteUpdaterParameters( String configRef, @@ -14,7 +14,7 @@ public record SiriETLiteUpdaterParameters( boolean fuzzyTripMatching, HttpHeaders httpRequestHeaders ) - implements SiriETUpdater.Parameters, SiriETLiteHttpTripUpdateSource.Parameters { + implements SiriETUpdaterParameters, SiriETLiteHttpTripUpdateSource.Parameters { @Override public String url() { return uri.toString(); From 095d01fb466df0f0a75b3d7a20792cc3eba00eff Mon Sep 17 00:00:00 2001 From: eibakke Date: Mon, 17 Nov 2025 15:18:44 +0100 Subject: [PATCH 37/40] Removes the SiriETCarpoolingUpdaterConfig.java and SiriETCarpoolingUpdaterParameters.java and reuses the vanilla versions. --- .../updater/SiriETCarpoolingUpdater.java | 10 ++-- .../SiriETCarpoolingUpdaterParameters.java | 27 --------- .../config/routerconfig/UpdatersConfig.java | 6 +- .../SiriETCarpoolingUpdaterConfig.java | 55 ------------------- .../updater/UpdatersParameters.java | 3 +- .../configure/UpdaterConfigurator.java | 6 -- .../siri/updater/SiriETUpdaterParameters.java | 3 +- 7 files changed, 9 insertions(+), 101 deletions(-) delete mode 100644 application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java delete mode 100644 application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java index 71dbb1c1032..4e8532f1799 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java @@ -6,6 +6,7 @@ import org.opentripplanner.updater.support.siri.SiriFileLoader; import org.opentripplanner.updater.support.siri.SiriHttpLoader; import org.opentripplanner.updater.support.siri.SiriLoader; +import org.opentripplanner.updater.trip.siri.updater.DefaultSiriETUpdaterParameters; import org.opentripplanner.updater.trip.siri.updater.EstimatedTimetableSource; import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; import org.opentripplanner.utils.tostring.ToStringBuilder; @@ -27,14 +28,11 @@ public class SiriETCarpoolingUpdater extends PollingGraphUpdater { private final CarpoolSiriMapper mapper; public SiriETCarpoolingUpdater( - SiriETCarpoolingUpdaterParameters config, + DefaultSiriETUpdaterParameters config, CarpoolingRepository repository ) { super(config); - this.updateSource = new SiriETHttpTripUpdateSource( - config.sourceParameters(), - siriLoader(config) - ); + this.updateSource = new SiriETHttpTripUpdateSource(config, siriLoader(config)); this.repository = repository; this.blockReadinessUntilInitialized = config.blockReadinessUntilInitialized(); @@ -122,7 +120,7 @@ public String toString() { .toString(); } - private static SiriLoader siriLoader(SiriETCarpoolingUpdaterParameters config) { + private static SiriLoader siriLoader(DefaultSiriETUpdaterParameters config) { // Load real-time updates from a file. if (SiriFileLoader.matchesUrl(config.url())) { return new SiriFileLoader(config.url()); diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java deleted file mode 100644 index 8cdba1e2cff..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdaterParameters.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.opentripplanner.ext.carpooling.updater; - -import java.time.Duration; -import org.opentripplanner.updater.spi.HttpHeaders; -import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; -import org.opentripplanner.updater.trip.UrlUpdaterParameters; -import org.opentripplanner.updater.trip.siri.updater.SiriETHttpTripUpdateSource; - -public record SiriETCarpoolingUpdaterParameters( - String configRef, - String feedId, - boolean blockReadinessUntilInitialized, - String url, - Duration frequency, - String requestorRef, - Duration timeout, - Duration previewInterval, - boolean fuzzyTripMatching, - HttpHeaders httpRequestHeaders, - boolean producerMetrics -) - implements - UrlUpdaterParameters, PollingGraphUpdaterParameters, SiriETHttpTripUpdateSource.Parameters { - public SiriETHttpTripUpdateSource.Parameters sourceParameters() { - return this; - } -} diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java index c42b6892cf3..d5e8dc472d8 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java @@ -24,7 +24,6 @@ import java.util.List; import java.util.function.BiFunction; import javax.annotation.Nullable; -import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureETUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureSXUpdaterParameters; import org.opentripplanner.ext.siri.updater.mqtt.MqttSiriETUpdaterParameters; @@ -34,7 +33,6 @@ import org.opentripplanner.standalone.config.routerconfig.updaters.GtfsRealtimeAlertsUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.MqttGtfsRealtimeUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.PollingTripUpdaterConfig; -import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETCarpoolingUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETGooglePubsubUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETLiteUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETMqttUpdaterConfig; @@ -183,7 +181,7 @@ public List getSiriETUpdaterParameters() { } @Override - public List getSiriETCarpoolingUpdaterParameters() { + public List getSiriETCarpoolingUpdaterParameters() { return getParameters(Type.SIRI_ET_CARPOOLING_UPDATER); } @@ -248,7 +246,7 @@ public enum Type { REAL_TIME_ALERTS(GtfsRealtimeAlertsUpdaterConfig::create), VEHICLE_POSITIONS(VehiclePositionsUpdaterConfig::create), SIRI_ET_UPDATER(SiriETUpdaterConfig::create), - SIRI_ET_CARPOOLING_UPDATER(SiriETCarpoolingUpdaterConfig::create), + SIRI_ET_CARPOOLING_UPDATER(SiriETUpdaterConfig::create), SIRI_ET_LITE(SiriETLiteUpdaterConfig::create), SIRI_ET_GOOGLE_PUBSUB_UPDATER(SiriETGooglePubsubUpdaterConfig::create), SIRI_SX_UPDATER(SiriSXUpdaterConfig::create), diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java deleted file mode 100644 index 45281eabbfa..00000000000 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/SiriETCarpoolingUpdaterConfig.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.opentripplanner.standalone.config.routerconfig.updaters; - -import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_0; -import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_3; -import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; - -import java.time.Duration; -import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; -import org.opentripplanner.standalone.config.framework.json.NodeAdapter; - -public class SiriETCarpoolingUpdaterConfig { - - public static SiriETCarpoolingUpdaterParameters create(String configRef, NodeAdapter c) { - return new SiriETCarpoolingUpdaterParameters( - configRef, - c.of("feedId").since(V2_0).summary("The ID of the feed to apply the updates to.").asString(), - c - .of("blockReadinessUntilInitialized") - .since(V2_0) - .summary( - "Whether catching up with the updates should block the readiness check from returning a 'ready' result." - ) - .asBoolean(false), - c - .of("url") - .since(V2_0) - .summary("The URL to send the HTTP requests to.") - .description(SiriSXUpdaterConfig.URL_DESCRIPTION) - .asString(), - c - .of("frequency") - .since(V2_0) - .summary("How often the updates should be retrieved.") - .asDuration(Duration.ofMinutes(1)), - c.of("requestorRef").since(V2_0).summary("The requester reference.").asString(null), - c - .of("timeout") - .since(V2_0) - .summary("The HTTP timeout to download the updates.") - .asDuration(Duration.ofSeconds(15)), - c.of("previewInterval").since(V2_0).summary("TODO").asDuration(null), - c - .of("fuzzyTripMatching") - .since(V2_0) - .summary("If the fuzzy trip matcher should be used to match trips.") - .asBoolean(false), - HttpHeadersConfig.headers(c, V2_3), - c - .of("producerMetrics") - .since(V2_7) - .summary("If failure, success, and warning metrics should be collected per producer.") - .asBoolean(false) - ); - } -} diff --git a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java index c12c8693ad6..c91800c929d 100644 --- a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java @@ -1,7 +1,6 @@ package org.opentripplanner.updater; import java.util.List; -import org.opentripplanner.ext.carpooling.updater.SiriETCarpoolingUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureETUpdaterParameters; import org.opentripplanner.ext.siri.updater.azure.SiriAzureSXUpdaterParameters; import org.opentripplanner.ext.siri.updater.mqtt.MqttSiriETUpdaterParameters; @@ -49,7 +48,7 @@ public interface UpdatersParameters { List getSiriAzureSXUpdaterParameters(); - List getSiriETCarpoolingUpdaterParameters(); + List getSiriETCarpoolingUpdaterParameters(); List getMqttSiriETUpdaterParameters(); } diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index e2f2b2a8efc..52db268c596 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -18,8 +18,6 @@ import org.opentripplanner.service.realtimevehicles.RealtimeVehicleRepository; import org.opentripplanner.service.vehicleparking.VehicleParkingRepository; import org.opentripplanner.service.vehiclerental.VehicleRentalRepository; -import org.opentripplanner.street.model.StreetLimitationParameters; -import org.opentripplanner.street.service.DefaultStreetLimitationParametersService; import org.opentripplanner.transit.service.TimetableRepository; import org.opentripplanner.updater.DefaultRealTimeUpdateContext; import org.opentripplanner.updater.GraphUpdaterManager; @@ -196,10 +194,6 @@ private List createUpdatersFromConfig() { updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter())); } for (var configItem : updatersParameters.getSiriETCarpoolingUpdaterParameters()) { - // Create a default street limitation parameters service for the updater - var streetLimitationParametersService = new DefaultStreetLimitationParametersService( - new StreetLimitationParameters() - ); updaters.add(new SiriETCarpoolingUpdater(configItem, carpoolingRepository)); } for (var configItem : updatersParameters.getSiriETLiteUpdaterParameters()) { diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java index ecee36be770..b7701449007 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/SiriETUpdaterParameters.java @@ -3,7 +3,8 @@ import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; import org.opentripplanner.updater.trip.UrlUpdaterParameters; -public interface SiriETUpdaterParameters extends UrlUpdaterParameters, PollingGraphUpdaterParameters { +public interface SiriETUpdaterParameters + extends UrlUpdaterParameters, PollingGraphUpdaterParameters { String url(); boolean blockReadinessUntilInitialized(); From d739459a485aa8968d1429752d5ecfcd0ecfce0d Mon Sep 17 00:00:00 2001 From: eibakke Date: Tue, 18 Nov 2025 15:59:42 +0100 Subject: [PATCH 38/40] Adapts to new setup for temporary vertices for use in A* routing. --- .../ext/carpooling/CarpoolingService.java | 10 +- .../configure/CarpoolingModule.java | 11 +- .../routing/CarpoolStreetRouter.java | 108 +++++++++++++----- .../routing/InsertionEvaluator.java | 17 +-- .../carpooling/routing/RoutingFunction.java | 7 +- .../service/DefaultCarpoolingService.java | 36 +++--- .../routing/algorithm/RoutingWorker.java | 11 +- .../routing/InsertionEvaluatorTest.java | 26 ++--- 8 files changed, 153 insertions(+), 73 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index 9f4dd19d949..bc3fe0ea28f 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -3,6 +3,8 @@ import java.util.List; import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.linking.LinkingContext; +import org.opentripplanner.routing.linking.TemporaryVerticesContainer; /** * Service for finding carpooling options by matching passenger requests with available driver trips. @@ -17,9 +19,15 @@ public interface CarpoolingService { *

      * * @param request the routing request containing passenger origin, destination, and preferences + * @param linkingContext linking context with pre-linked vertices for the request + * @param temporaryVerticesContainer container for managing temporary vertices created during routing * @return list of carpool itineraries, sorted by quality (additional travel time), may be empty * if no compatible trips found. Results are limited to avoid overwhelming users. * @throws IllegalArgumentException if request is null */ - List route(RouteRequest request); + List route( + RouteRequest request, + LinkingContext linkingContext, + TemporaryVerticesContainer temporaryVerticesContainer + ); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java index 0d3f1890df5..dbd514ad6ba 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java @@ -9,7 +9,6 @@ import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository; import org.opentripplanner.ext.carpooling.service.DefaultCarpoolingService; import org.opentripplanner.framework.application.OTPFeature; -import org.opentripplanner.routing.graph.Graph; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.transit.service.TransitService; @@ -31,20 +30,18 @@ public CarpoolingRepository provideCarpoolingRepository() { @Nullable public static CarpoolingService provideCarpoolingService( @Nullable CarpoolingRepository repository, - Graph graph, - VertexLinker vertexLinker, StreetLimitationParametersService streetLimitationParametersService, - TransitService transitService + TransitService transitService, + VertexLinker vertexLinker ) { if (OTPFeature.CarPooling.isOff()) { return null; } return new DefaultCarpoolingService( repository, - graph, - vertexLinker, streetLimitationParametersService, - transitService + transitService, + vertexLinker ); } } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java index a12ba912c96..e081cd47dd6 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java @@ -5,16 +5,23 @@ import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.astar.strategy.DurationSkipEdgeStrategy; import org.opentripplanner.astar.strategy.PathComparator; +import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.model.GenericLocation; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; import org.opentripplanner.routing.api.request.request.StreetRequest; -import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.LinkingContext; +import org.opentripplanner.routing.linking.TemporaryVerticesContainer; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.model.edge.Edge; +import org.opentripplanner.street.model.edge.LinkingDirection; +import org.opentripplanner.street.model.edge.TemporaryFreeEdge; +import org.opentripplanner.street.model.vertex.TemporaryStreetLocation; +import org.opentripplanner.street.model.vertex.TemporaryVertex; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.StreetSearchBuilder; -import org.opentripplanner.street.search.TemporaryVerticesContainer; +import org.opentripplanner.street.search.TraverseMode; +import org.opentripplanner.street.search.TraverseModeSet; import org.opentripplanner.street.search.state.State; import org.opentripplanner.street.search.strategy.DominanceFunctions; import org.opentripplanner.street.search.strategy.EuclideanRemainingWeightHeuristic; @@ -44,58 +51,56 @@ public class CarpoolStreetRouter { private static final Logger LOG = LoggerFactory.getLogger(CarpoolStreetRouter.class); - private final Graph graph; - private final VertexLinker vertexLinker; private final StreetLimitationParametersService streetLimitationParametersService; private final RouteRequest request; + private final VertexLinker vertexLinker; + private final TemporaryVerticesContainer temporaryVerticesContainer; /** * Creates a new carpool street router. * - * @param graph the street network graph - * @param vertexLinker links coordinates to graph vertices * @param streetLimitationParametersService provides street routing parameters (speed limits, etc.) * @param request the route request containing preferences and timing + * @param vertexLinker links coordinates to graph vertices + * @param temporaryVerticesContainer container for temporary vertices and edges */ public CarpoolStreetRouter( - Graph graph, - VertexLinker vertexLinker, StreetLimitationParametersService streetLimitationParametersService, - RouteRequest request + RouteRequest request, + VertexLinker vertexLinker, + TemporaryVerticesContainer temporaryVerticesContainer ) { - this.graph = graph; - this.vertexLinker = vertexLinker; this.streetLimitationParametersService = streetLimitationParametersService; this.request = request; + this.vertexLinker = vertexLinker; + this.temporaryVerticesContainer = temporaryVerticesContainer; } /** * Routes from one location to another using A* street search. *

      - * Creates temporary vertices at the given coordinates, performs A* search, - * and returns the best path found. Returns null if routing fails. + * Uses the provided linking context to find vertices at the given coordinates, + * performs A* search, and returns the best path found. Returns null if routing fails. * * @param from origin coordinate * @param to destination coordinate + * @param linkingContext linking context containing pre-linked vertices * @return the best path found, or null if routing failed */ - public GraphPath route(GenericLocation from, GenericLocation to) { + public GraphPath route( + GenericLocation from, + GenericLocation to, + LinkingContext linkingContext + ) { try { - var tempVertices = new TemporaryVerticesContainer( - graph, - vertexLinker, - null, - from, - to, - StreetMode.CAR, - StreetMode.CAR - ); + var fromVertices = getOrCreateVertices(from, linkingContext); + var toVertices = getOrCreateVertices(to, linkingContext); return carpoolRouting( new StreetRequest(StreetMode.CAR), - tempVertices.getFromVertices(), - tempVertices.getToVertices(), - streetLimitationParametersService.getMaxCarSpeed() + fromVertices, + toVertices, + streetLimitationParametersService.maxCarSpeed() ); } catch (Exception e) { LOG.warn("Routing failed from {} to {}: {}", from, to, e.getMessage()); @@ -103,6 +108,57 @@ public GraphPath route(GenericLocation from, GenericLocatio } } + /** + * Gets vertices for a location, either from the LinkingContext or by creating + * temporary vertices on-demand. + *

      + * This method first checks if vertices already exist in the LinkingContext (which + * contains pre-linked vertices for the passenger's origin and destination). If not + * found (e.g., for driver trip waypoints), it creates a temporary vertex on-demand + * using VertexLinker and adds it to the TemporaryVerticesContainer for automatic cleanup. + *

      + * This follows the pattern used in VertexCreationService but uses VertexLinker directly + * to respect package boundaries (VertexCreationService is in the 'internal' package). + * + * @param location the location to get vertices for + * @param linkingContext linking context to check for existing vertices + * @return set of vertices for the location (either existing or newly created) + */ + private Set getOrCreateVertices(GenericLocation location, LinkingContext linkingContext) { + var vertices = linkingContext.findVertices(location); + if (!vertices.isEmpty()) { + return vertices; + } + + var coordinate = location.getCoordinate(); + var tempVertex = new TemporaryStreetLocation( + coordinate, + new NonLocalizedString(location.label != null ? location.label : "Waypoint") + ); + + var disposableEdges = vertexLinker.linkVertexForRequest( + tempVertex, + new TraverseModeSet(TraverseMode.CAR), + LinkingDirection.BIDIRECTIONAL, + (vertex, streetVertex) -> + List.of( + TemporaryFreeEdge.createTemporaryFreeEdge((TemporaryVertex) vertex, streetVertex), + TemporaryFreeEdge.createTemporaryFreeEdge(streetVertex, (TemporaryVertex) vertex) + ) + ); + + // Add to container for automatic cleanup + temporaryVerticesContainer.addEdgeCollection(disposableEdges); + + if (tempVertex.getIncoming().isEmpty() && tempVertex.getOutgoing().isEmpty()) { + LOG.warn("Couldn't link coordinate {} to graph for location {}", coordinate, location); + } else { + LOG.debug("Created temporary vertex for coordinate {} (not in LinkingContext)", coordinate); + } + + return Set.of(tempVertex); + } + /** * Core A* routing for carpooling optimized for car travel. *

      diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java index 2c7889f38fe..e561e539011 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java @@ -11,6 +11,7 @@ import org.opentripplanner.ext.carpooling.model.CarpoolTrip; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.routing.linking.LinkingContext; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.state.State; @@ -36,19 +37,23 @@ public class InsertionEvaluator { private final RoutingFunction routingFunction; private final PassengerDelayConstraints delayConstraints; + private final LinkingContext linkingContext; /** - * Creates an evaluator with the specified routing function and delay constraints. + * Creates an evaluator with the specified routing function, delay constraints, and linking context. * * @param routingFunction Function that performs A* routing between coordinates * @param delayConstraints Constraints for acceptable passenger delays + * @param linkingContext Linking context with pre-linked vertices for routing */ public InsertionEvaluator( RoutingFunction routingFunction, - PassengerDelayConstraints delayConstraints + PassengerDelayConstraints delayConstraints, + LinkingContext linkingContext ) { this.routingFunction = routingFunction; this.delayConstraints = delayConstraints; + this.linkingContext = linkingContext; } /** @@ -69,7 +74,7 @@ private GraphPath[] routeBaselineSegments(List segment = routingFunction.route(from, to); + GraphPath segment = routingFunction.route(from, to, linkingContext); if (segment == null) { LOG.debug("Baseline routing failed for segment {} → {}", i, i + 1); return null; @@ -224,10 +229,6 @@ private InsertionCandidate evaluateInsertion( *

      This is the key optimization: instead of routing ALL segments again, * we only route segments that changed due to passenger insertion. * - *

      Segments are reused when both endpoints match between baseline and modified routes. - * Endpoint matching uses {@link WgsCoordinate#equals()} which compares coordinates with - * 7-decimal precision (~1cm tolerance). - * * @param originalPoints Route points before passenger insertion * @param baselineSegments Pre-routed segments for baseline route * @param pickupPos Passenger pickup position (1-indexed) @@ -276,7 +277,7 @@ private List> buildModifiedSegments( toCoord.longitude() ); - segment = routingFunction.route(from, to); + segment = routingFunction.route(from, to, linkingContext); if (segment == null) { LOG.trace("Routing failed for new segment {} → {}", i, i + 1); return null; diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java index a36fdcd2258..78fefd3a5ff 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java @@ -2,6 +2,7 @@ import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.model.GenericLocation; +import org.opentripplanner.routing.linking.LinkingContext; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.state.State; @@ -11,5 +12,9 @@ */ @FunctionalInterface public interface RoutingFunction { - GraphPath route(GenericLocation from, GenericLocation to); + GraphPath route( + GenericLocation from, + GenericLocation to, + LinkingContext linkingContext + ); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index 0bca057e16f..9a69b889bba 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -22,7 +22,8 @@ import org.opentripplanner.routing.api.response.RoutingError; import org.opentripplanner.routing.api.response.RoutingErrorCode; import org.opentripplanner.routing.error.RoutingValidationException; -import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.linking.LinkingContext; +import org.opentripplanner.routing.linking.TemporaryVerticesContainer; import org.opentripplanner.routing.linking.VertexLinker; import org.opentripplanner.street.service.StreetLimitationParametersService; import org.opentripplanner.transit.service.TransitService; @@ -54,7 +55,6 @@ *

      Component Dependencies

      *
        *
      • {@link CarpoolingRepository}: Source of available driver trips
      • - *
      • {@link Graph}: Street network for routing calculations
      • *
      • {@link VertexLinker}: Links coordinates to graph vertices
      • *
      • {@link StreetLimitationParametersService}: Street routing configuration
      • *
      • {@link FilterChain}: Pre-screening filters
      • @@ -75,13 +75,12 @@ public class DefaultCarpoolingService implements CarpoolingService { private static final Duration DEFAULT_SEARCH_WINDOW = Duration.ofMinutes(30); private final CarpoolingRepository repository; - private final Graph graph; - private final VertexLinker vertexLinker; private final StreetLimitationParametersService streetLimitationParametersService; private final FilterChain preFilters; private final CarpoolItineraryMapper itineraryMapper; private final PassengerDelayConstraints delayConstraints; private final InsertionPositionFinder positionFinder; + private final VertexLinker vertexLinker; /** * Creates a new carpooling service with the specified dependencies. @@ -90,32 +89,33 @@ public class DefaultCarpoolingService implements CarpoolingService { * is currently hardcoded but could be made configurable in future versions. * * @param repository provides access to active driver trips, must not be null - * @param graph the street network used for routing calculations, must not be null - * @param vertexLinker links coordinates to graph vertices for routing, must not be null * @param streetLimitationParametersService provides street routing configuration including * speed limits, must not be null * @param transitService provides timezone from GTFS agency data for time conversions, must not be null + * @param vertexLinker links coordinates to graph vertices, must not be null * @throws NullPointerException if any parameter is null */ public DefaultCarpoolingService( CarpoolingRepository repository, - Graph graph, - VertexLinker vertexLinker, StreetLimitationParametersService streetLimitationParametersService, - TransitService transitService + TransitService transitService, + VertexLinker vertexLinker ) { this.repository = repository; - this.graph = graph; - this.vertexLinker = vertexLinker; this.streetLimitationParametersService = streetLimitationParametersService; this.preFilters = FilterChain.standard(); this.itineraryMapper = new CarpoolItineraryMapper(transitService.getTimeZone()); this.delayConstraints = new PassengerDelayConstraints(); this.positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator()); + this.vertexLinker = vertexLinker; } @Override - public List route(RouteRequest request) throws RoutingValidationException { + public List route( + RouteRequest request, + LinkingContext linkingContext, + TemporaryVerticesContainer temporaryVerticesContainer + ) throws RoutingValidationException { validateRequest(request); WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate()); @@ -159,12 +159,16 @@ public List route(RouteRequest request) throws RoutingValidationExcep } var router = new CarpoolStreetRouter( - graph, - vertexLinker, streetLimitationParametersService, - request + request, + vertexLinker, + temporaryVerticesContainer + ); + var insertionEvaluator = new InsertionEvaluator( + router::route, + delayConstraints, + linkingContext ); - var insertionEvaluator = new InsertionEvaluator(router::route, delayConstraints); // Find optimal insertions for remaining trips var insertionCandidates = candidateTrips diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java index f30f755a6f4..2c262fdda11 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java @@ -74,6 +74,10 @@ public class RoutingWorker { @Nullable private LinkingContext currentLinkingContext = null; + /// Store container for access in routeCarpooling() which may run in CompletableFuture + @Nullable + private TemporaryVerticesContainer temporaryVerticesContainer = null; + public RoutingWorker( OtpServerRequestContext serverContext, RouteRequest orginalRequest, @@ -104,6 +108,7 @@ public RoutingResponse route() { var result = RoutingResult.empty(); try (var temporaryVerticesContainer = new TemporaryVerticesContainer()) { + this.temporaryVerticesContainer = temporaryVerticesContainer; this.currentLinkingContext = createLinkingContext(temporaryVerticesContainer); if (OTPFeature.ParallelRouting.isOn()) { @@ -273,7 +278,11 @@ private RoutingResult routeCarpooling() { } debugTimingAggregator.startedDirectCarpoolRouter(); try { - return RoutingResult.ok(serverContext.carpoolingService().route(request)); + return RoutingResult.ok( + serverContext + .carpoolingService() + .route(request, linkingContext(), temporaryVerticesContainer) + ); } catch (RoutingValidationException e) { return RoutingResult.failed(e.getRoutingErrors()); } finally { diff --git a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java index 3a0c68e5a91..23977a80c3f 100644 --- a/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java +++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java @@ -63,7 +63,7 @@ private InsertionCandidate findOptimalInsertion( return null; } - var evaluator = new InsertionEvaluator(routingFunction, delayConstraints); + var evaluator = new InsertionEvaluator(routingFunction, delayConstraints, null); return evaluator.findBestInsertion(trip, viablePositions, passengerPickup, passengerDropoff); } @@ -72,7 +72,7 @@ void findOptimalInsertion_noValidPositions_returnsNull() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); // Routing function returns null (simulating routing failure) // This causes evaluator to skip all positions - RoutingFunction routingFunction = (from, to) -> null; + RoutingFunction routingFunction = (from, to, linkingContext) -> null; var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); @@ -85,7 +85,7 @@ void findOptimalInsertion_oneValidPosition_returnsCandidate() { var mockPath = createGraphPath(); - RoutingFunction routingFunction = (from, to) -> mockPath; + RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath; var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); @@ -107,7 +107,7 @@ void findOptimalInsertion_routingFails_skipsPosition() { // 2. First insertion attempt fails (null for first segment) // 3. Second insertion attempt succeeds (mockPath for all segments) final int[] callCount = { 0 }; - RoutingFunction routingFunction = (from, to) -> { + RoutingFunction routingFunction = (from, to, linkingContext) -> { int call = callCount[0]++; if (call < 2) { return mockPath; @@ -135,7 +135,7 @@ void findOptimalInsertion_exceedsDeviationBudget_returnsNull() { // Additional = 50 min, exceeds 5 min budget var mockPath = createGraphPath(Duration.ofMinutes(20)); - RoutingFunction routingFunction = (from, to) -> mockPath; + RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath; var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); @@ -151,7 +151,7 @@ void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { var mockPath = createGraphPath(); - RoutingFunction routingFunction = (from, to) -> mockPath; + RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath; assertDoesNotThrow(() -> findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST, routingFunction) @@ -162,7 +162,7 @@ void findOptimalInsertion_tripWithStops_evaluatesAllPositions() { void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() { var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH); - RoutingFunction routingFunction = (from, to) -> null; + RoutingFunction routingFunction = (from, to, linkingContext) -> null; var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); @@ -199,7 +199,7 @@ void findOptimalInsertion_selectsMinimumAdditionalDuration() { mockPath7, }; final int[] callCount = { 0 }; - RoutingFunction routingFunction = (from, to) -> { + RoutingFunction routingFunction = (from, to, linkingContext) -> { int call = callCount[0]++; if (call == 0) { return mockPath10; @@ -225,7 +225,7 @@ void findOptimalInsertion_simpleTrip_hasExpectedStructure() { var mockPath = createGraphPath(); - RoutingFunction routingFunction = (from, to) -> mockPath; + RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath; var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction); @@ -274,7 +274,7 @@ void findOptimalInsertion_insertBetweenTwoPoints_routesAllSegments() { segmentCD, }; final int[] callCount = { 0 }; - RoutingFunction routingFunction = (from, to) -> { + RoutingFunction routingFunction = (from, to, linkingContext) -> { int call = callCount[0]++; return call < paths.length ? paths[call] : segmentAC; }; @@ -329,7 +329,7 @@ void findOptimalInsertion_insertAtEnd_reusesMostSegments() { var mockPath = createGraphPath(Duration.ofMinutes(5)); final int[] callCount = { 0 }; - RoutingFunction routingFunction = (from, to) -> { + RoutingFunction routingFunction = (from, to, linkingContext) -> { callCount[0]++; return mockPath; }; @@ -369,7 +369,7 @@ void findOptimalInsertion_pickupAtExistingPoint_handlesCorrectly() { var mockPath = createGraphPath(Duration.ofMinutes(5)); final int[] callCount = { 0 }; - RoutingFunction routingFunction = (from, to) -> { + RoutingFunction routingFunction = (from, to, linkingContext) -> { callCount[0]++; return mockPath; }; @@ -397,7 +397,7 @@ void findOptimalInsertion_singleSegmentTrip_routesAllNewSegments() { var mockPath = createGraphPath(Duration.ofMinutes(5)); final int[] callCount = { 0 }; - RoutingFunction routingFunction = (from, to) -> { + RoutingFunction routingFunction = (from, to, linkingContext) -> { callCount[0]++; return mockPath; }; From e5a7466c8dcfdd82adf85f66dc2c8445f3cba4c5 Mon Sep 17 00:00:00 2001 From: eibakke Date: Wed, 19 Nov 2025 15:47:39 +0100 Subject: [PATCH 39/40] Creates a TemporaryVerticesContainer local to the CarpoolingService's route method. --- .../ext/carpooling/CarpoolingService.java | 8 +- .../service/DefaultCarpoolingService.java | 115 +++++++++--------- .../routing/algorithm/RoutingWorker.java | 11 +- 3 files changed, 59 insertions(+), 75 deletions(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java index bc3fe0ea28f..efc70da73fc 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java @@ -4,7 +4,6 @@ import org.opentripplanner.model.plan.Itinerary; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.linking.LinkingContext; -import org.opentripplanner.routing.linking.TemporaryVerticesContainer; /** * Service for finding carpooling options by matching passenger requests with available driver trips. @@ -20,14 +19,9 @@ public interface CarpoolingService { * * @param request the routing request containing passenger origin, destination, and preferences * @param linkingContext linking context with pre-linked vertices for the request - * @param temporaryVerticesContainer container for managing temporary vertices created during routing * @return list of carpool itineraries, sorted by quality (additional travel time), may be empty * if no compatible trips found. Results are limited to avoid overwhelming users. * @throws IllegalArgumentException if request is null */ - List route( - RouteRequest request, - LinkingContext linkingContext, - TemporaryVerticesContainer temporaryVerticesContainer - ); + List route(RouteRequest request, LinkingContext linkingContext); } diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index 9a69b889bba..08a4e603a61 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -111,11 +111,8 @@ public DefaultCarpoolingService( } @Override - public List route( - RouteRequest request, - LinkingContext linkingContext, - TemporaryVerticesContainer temporaryVerticesContainer - ) throws RoutingValidationException { + public List route(RouteRequest request, LinkingContext linkingContext) + throws RoutingValidationException { validateRequest(request); WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate()); @@ -158,60 +155,62 @@ public List route( return List.of(); } - var router = new CarpoolStreetRouter( - streetLimitationParametersService, - request, - vertexLinker, - temporaryVerticesContainer - ); - var insertionEvaluator = new InsertionEvaluator( - router::route, - delayConstraints, - linkingContext - ); - - // Find optimal insertions for remaining trips - var insertionCandidates = candidateTrips - .stream() - .map(trip -> { - List viablePositions = positionFinder.findViablePositions( - trip, - passengerPickup, - passengerDropoff - ); - - if (viablePositions.isEmpty()) { - LOG.debug("No viable positions found for trip {} (avoided all routing!)", trip.getId()); - return null; - } - - LOG.debug( - "{} viable positions found for trip {}, evaluating with routing", - viablePositions.size(), - trip.getId() - ); - - // Evaluate only viable positions with expensive routing - return insertionEvaluator.findBestInsertion( - trip, - viablePositions, - passengerPickup, - passengerDropoff - ); - }) - .filter(Objects::nonNull) - .sorted(Comparator.comparing(InsertionCandidate::additionalDuration)) - .limit(DEFAULT_MAX_CARPOOL_RESULTS) - .toList(); - - LOG.debug("Found {} viable insertion candidates", insertionCandidates.size()); + var itineraries = List.of(); + try (var temporaryVerticesContainer = new TemporaryVerticesContainer()) { + var router = new CarpoolStreetRouter( + streetLimitationParametersService, + request, + vertexLinker, + temporaryVerticesContainer + ); + var insertionEvaluator = new InsertionEvaluator( + router::route, + delayConstraints, + linkingContext + ); - // Map to itineraries - var itineraries = insertionCandidates - .stream() - .map(candidate -> itineraryMapper.toItinerary(request, candidate)) - .filter(Objects::nonNull) - .toList(); + // Find optimal insertions for remaining trips + var insertionCandidates = candidateTrips + .stream() + .map(trip -> { + List viablePositions = positionFinder.findViablePositions( + trip, + passengerPickup, + passengerDropoff + ); + + if (viablePositions.isEmpty()) { + LOG.debug("No viable positions found for trip {} (avoided all routing!)", trip.getId()); + return null; + } + + LOG.debug( + "{} viable positions found for trip {}, evaluating with routing", + viablePositions.size(), + trip.getId() + ); + + // Evaluate only viable positions with expensive routing + return insertionEvaluator.findBestInsertion( + trip, + viablePositions, + passengerPickup, + passengerDropoff + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(InsertionCandidate::additionalDuration)) + .limit(DEFAULT_MAX_CARPOOL_RESULTS) + .toList(); + + LOG.debug("Found {} viable insertion candidates", insertionCandidates.size()); + + itineraries = insertionCandidates + .stream() + .map(candidate -> itineraryMapper.toItinerary(request, candidate)) + .filter(Objects::nonNull) + .toList(); + } LOG.info("Returning {} carpool itineraries", itineraries.size()); return itineraries; diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java index 2c262fdda11..a06e1b7e850 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java @@ -74,10 +74,6 @@ public class RoutingWorker { @Nullable private LinkingContext currentLinkingContext = null; - /// Store container for access in routeCarpooling() which may run in CompletableFuture - @Nullable - private TemporaryVerticesContainer temporaryVerticesContainer = null; - public RoutingWorker( OtpServerRequestContext serverContext, RouteRequest orginalRequest, @@ -108,7 +104,6 @@ public RoutingResponse route() { var result = RoutingResult.empty(); try (var temporaryVerticesContainer = new TemporaryVerticesContainer()) { - this.temporaryVerticesContainer = temporaryVerticesContainer; this.currentLinkingContext = createLinkingContext(temporaryVerticesContainer); if (OTPFeature.ParallelRouting.isOn()) { @@ -278,11 +273,7 @@ private RoutingResult routeCarpooling() { } debugTimingAggregator.startedDirectCarpoolRouter(); try { - return RoutingResult.ok( - serverContext - .carpoolingService() - .route(request, linkingContext(), temporaryVerticesContainer) - ); + return RoutingResult.ok(serverContext.carpoolingService().route(request, linkingContext())); } catch (RoutingValidationException e) { return RoutingResult.failed(e.getRoutingErrors()); } finally { From 0c392456b0a058094c5141eac9d3eb93553004e6 Mon Sep 17 00:00:00 2001 From: Eivind Morris Bakke Date: Thu, 20 Nov 2025 09:47:56 +0100 Subject: [PATCH 40/40] Update application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java Co-authored-by: Joel Lappalainen --- .../ext/carpooling/service/DefaultCarpoolingService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java index 08a4e603a61..0a756f354cf 100644 --- a/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java +++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingService.java @@ -219,7 +219,7 @@ public List route(RouteRequest request, LinkingContext linkingContext private void validateRequest(RouteRequest request) throws RoutingValidationException { Objects.requireNonNull(request.from()); Objects.requireNonNull(request.to()); - if (request.from().lat == null || request.from() == null) { + if (request.from().lat == null || request.from().lng == null) { throw new RoutingValidationException( List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.FROM_PLACE)) );