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/CarpoolingRepository.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java
new file mode 100644
index 00000000000..a7b1ecccb1d
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java
@@ -0,0 +1,42 @@
+package org.opentripplanner.ext.carpooling;
+
+import java.util.Collection;
+import org.opentripplanner.ext.carpooling.model.CarpoolTrip;
+
+/**
+ * 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
new file mode 100644
index 00000000000..efc70da73fc
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingService.java
@@ -0,0 +1,27 @@
+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.linking.LinkingContext;
+
+/**
+ * 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.
+ */
+public interface CarpoolingService {
+ /**
+ * Finds carpooling itineraries matching the passenger's routing request.
+ *
+ *
+ * @param request the routing request containing passenger origin, destination, and preferences
+ * @param linkingContext linking context with pre-linked vertices for the request
+ * @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);
+}
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/configure/CarpoolingModule.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java
new file mode 100644
index 00000000000..dbd514ad6ba
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/configure/CarpoolingModule.java
@@ -0,0 +1,47 @@
+package org.opentripplanner.ext.carpooling.configure;
+
+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;
+import org.opentripplanner.ext.carpooling.service.DefaultCarpoolingService;
+import org.opentripplanner.framework.application.OTPFeature;
+import org.opentripplanner.routing.linking.VertexLinker;
+import org.opentripplanner.street.service.StreetLimitationParametersService;
+import org.opentripplanner.transit.service.TransitService;
+
+@Module
+public class CarpoolingModule {
+
+ @Provides
+ @Singleton
+ @Nullable
+ public CarpoolingRepository provideCarpoolingRepository() {
+ if (OTPFeature.CarPooling.isOff()) {
+ return null;
+ }
+ return new DefaultCarpoolingRepository();
+ }
+
+ @Provides
+ @Nullable
+ public static CarpoolingService provideCarpoolingService(
+ @Nullable CarpoolingRepository repository,
+ StreetLimitationParametersService streetLimitationParametersService,
+ TransitService transitService,
+ VertexLinker vertexLinker
+ ) {
+ if (OTPFeature.CarPooling.isOff()) {
+ return null;
+ }
+ return new DefaultCarpoolingService(
+ repository,
+ streetLimitationParametersService,
+ transitService,
+ vertexLinker
+ );
+ }
+}
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..fe3ffa17b95
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraints.java
@@ -0,0 +1,110 @@
+package org.opentripplanner.ext.carpooling.constraints;
+
+import java.time.Duration;
+import org.opentripplanner.ext.carpooling.routing.InsertionPosition;
+import org.opentripplanner.utils.time.DurationUtils;
+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) {
+ this.maxDelay = DurationUtils.requireNonNegative(maxDelay);
+ }
+
+ /**
+ * Checks if a passenger insertion satisfies delay constraints.
+ *
+ * @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[] originalCumulativeDurations,
+ Duration[] modifiedCumulativeDurations,
+ int pickupPos,
+ int dropoffPos
+ ) {
+ // If no existing stops (only boarding and alighting), no constraint to check
+ if (originalCumulativeDurations.length <= 2) {
+ return true;
+ }
+
+ // Check delay at each existing stop (exclude boarding at 0 and alighting at end)
+ for (
+ int originalIndex = 1;
+ originalIndex < originalCumulativeDurations.length - 1;
+ originalIndex++
+ ) {
+ int modifiedIndex = InsertionPosition.mapOriginalIndex(originalIndex, pickupPos, dropoffPos);
+
+ Duration originalTime = originalCumulativeDurations[originalIndex];
+ Duration modifiedTime = modifiedCumulativeDurations[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;
+ }
+
+ /**
+ * 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/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..257e539e404
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilter.java
@@ -0,0 +1,114 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+import java.util.List;
+import org.opentripplanner.ext.carpooling.model.CarpoolTrip;
+import org.opentripplanner.framework.geometry.DirectionUtils;
+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 optimized 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.
+ * 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;
+
+ public DirectionalCompatibilityFilter() {
+ this(DEFAULT_BEARING_TOLERANCE_DEGREES);
+ }
+
+ public DirectionalCompatibilityFilter(double bearingToleranceDegrees) {
+ this.bearingToleranceDegrees = bearingToleranceDegrees;
+ }
+
+ @Override
+ public boolean accepts(
+ CarpoolTrip trip,
+ WgsCoordinate passengerPickup,
+ WgsCoordinate passengerDropoff
+ ) {
+ List routePoints = trip.routePoints();
+
+ if (routePoints.size() < 2) {
+ LOG.warn("Trip {} has fewer than 2 route points, rejecting", trip.getId());
+ return false;
+ }
+
+ double passengerBearing = DirectionUtils.getAzimuth(
+ passengerPickup.asJtsCoordinate(),
+ passengerDropoff.asJtsCoordinate()
+ );
+
+ for (int i = 0; i < routePoints.size() - 1; i++) {
+ if (isSegmentCompatible(routePoints.get(i), routePoints.get(i + 1), passengerBearing)) {
+ 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
+ if (isSegmentCompatible(routePoints.getFirst(), routePoints.getLast(), passengerBearing)) {
+ LOG.debug(
+ "Trip {} accepted: passenger journey aligns with full route ({} to {})",
+ trip.getId(),
+ routePoints.getFirst(),
+ routePoints.getLast()
+ );
+ 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;
+ }
+
+ double getBearingToleranceDegrees() {
+ return bearingToleranceDegrees;
+ }
+
+ /**
+ * 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
+ * @return true if segment bearing is within tolerance of passenger bearing
+ */
+ private boolean isSegmentCompatible(
+ WgsCoordinate segmentStart,
+ WgsCoordinate segmentEnd,
+ double passengerBearing
+ ) {
+ double segmentBearing = DirectionUtils.getAzimuth(
+ segmentStart.asJtsCoordinate(),
+ segmentEnd.asJtsCoordinate()
+ );
+
+ double bearingDiff = DirectionUtils.bearingDifference(segmentBearing, passengerBearing);
+
+ return bearingDiff <= 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
new file mode 100644
index 00000000000..2fd782d5af5
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilter.java
@@ -0,0 +1,95 @@
+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;
+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 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 {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DistanceBasedFilter.class);
+
+ 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
+ ) {
+ 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 = SphericalDistanceLibrary.fastDistance(
+ passengerPickup.asJtsCoordinate(),
+ segmentStart.asJtsCoordinate(),
+ segmentEnd.asJtsCoordinate()
+ );
+ double dropoffDistanceToSegment = SphericalDistanceLibrary.fastDistance(
+ passengerDropoff.asJtsCoordinate(),
+ segmentStart.asJtsCoordinate(),
+ segmentEnd.asJtsCoordinate()
+ );
+
+ // 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;
+ }
+ }
+
+ LOG.debug(
+ "Trip {} rejected by distance filter: passenger journey too far from all route segments (max: {:.0f}m)",
+ trip.getId(),
+ maxDistanceMeters
+ );
+ return false;
+ }
+
+ 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
new file mode 100644
index 00000000000..a782568096c
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/FilterChain.java
@@ -0,0 +1,77 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+import java.time.Duration;
+import java.time.Instant;
+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.
+ *
+ * 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 {
+
+ private final List filters;
+
+ public FilterChain(List filters) {
+ this.filters = 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(
+ List.of(
+ new CapacityFilter(),
+ new TimeBasedFilter(),
+ new DistanceBasedFilter(),
+ new DirectionalCompatibilityFilter()
+ )
+ );
+ }
+
+ @Override
+ public boolean accepts(
+ CarpoolTrip trip,
+ WgsCoordinate passengerPickup,
+ WgsCoordinate passengerDropoff
+ ) {
+ return filters
+ .stream()
+ .allMatch(filter -> filter.accepts(trip, passengerPickup, passengerDropoff));
+ }
+
+ @Override
+ public boolean accepts(
+ CarpoolTrip trip,
+ WgsCoordinate passengerPickup,
+ WgsCoordinate passengerDropoff,
+ Instant passengerDepartureTime,
+ Duration searchWindow
+ ) {
+ return filters
+ .stream()
+ .allMatch(filter ->
+ filter.accepts(
+ trip,
+ passengerPickup,
+ passengerDropoff,
+ passengerDepartureTime,
+ searchWindow
+ )
+ );
+ }
+}
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..565ca4b9328
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilter.java
@@ -0,0 +1,57 @@
+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);
+
+ @Override
+ public boolean accepts(
+ CarpoolTrip trip,
+ WgsCoordinate passengerPickup,
+ WgsCoordinate passengerDropoff
+ ) {
+ return true;
+ }
+
+ @Override
+ public boolean accepts(
+ CarpoolTrip trip,
+ WgsCoordinate passengerPickup,
+ WgsCoordinate passengerDropoff,
+ Instant passengerDepartureTime,
+ Duration searchWindow
+ ) {
+ Instant tripStartTime = trip.startTime().toInstant();
+
+ Duration timeDiff = Duration.between(tripStartTime, passengerDepartureTime).abs();
+
+ boolean withinWindow = timeDiff.compareTo(searchWindow) <= 0;
+
+ if (!withinWindow) {
+ LOG.debug(
+ "Trip {} rejected by time filter: trip departs at {}, passenger requests {}, diff = {} (window = {})",
+ trip.getId(),
+ trip.startTime(),
+ passengerDepartureTime,
+ timeDiff,
+ searchWindow
+ );
+ }
+
+ return withinWindow;
+ }
+}
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..0f7e3bae30d
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/filter/TripFilter.java
@@ -0,0 +1,49 @@
+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;
+
+/**
+ * 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, time, distance, 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);
+
+ /**
+ * 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
+ * @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,
+ Duration searchWindow
+ ) {
+ // Default: ignore time and delegate to coordinate-only method
+ return accepts(trip, passengerPickup, passengerDropoff);
+ }
+}
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..78b6c3baa0b
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/CarpoolItineraryMapper.java
@@ -0,0 +1,164 @@
+package org.opentripplanner.ext.carpooling.internal;
+
+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;
+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;
+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:
+ *
+ * - The passenger's requested departure time
+ * - When the driver arrives at the pickup location
+ *
+ *
+ * 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 {
+
+ 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.
+ *
+ * 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:
+ *
+ * - Driver pickup arrival: Driver's start time + pickup segment durations
+ * - Passenger start: max(requested time, driver arrival time)
+ * - Passenger end: start time + shared segment durations
+ *
+ *
+ * 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)
+ */
+ @Nullable
+ public Itinerary toItinerary(RouteRequest request, InsertionCandidate candidate) {
+ var sharedSegments = candidate.getSharedSegments();
+ if (sharedSegments.isEmpty()) {
+ return null;
+ }
+
+ 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);
+
+ var startTime = request.dateTime().isAfter(driverPickupTime.toInstant())
+ ? request.dateTime().atZone(timeZone)
+ : driverPickupTime;
+
+ // 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 firstSegment = sharedSegments.getFirst();
+ var lastSegment = sharedSegments.getLast();
+
+ Vertex fromVertex = firstSegment.states.getFirst().getVertex();
+ Vertex toVertex = lastSegment.states.getLast().getVertex();
+
+ var allEdges = sharedSegments.stream().flatMap(seg -> seg.edges.stream()).toList();
+
+ 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(allEdges, Edge::getGeometry))
+ .withDistanceMeters(allEdges.stream().mapToDouble(Edge::getDistanceMeters).sum())
+ .withGeneralizedCost((int) lastSegment.getWeight())
+ .build();
+
+ 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
new file mode 100644
index 00000000000..1f7634100f4
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepository.java
@@ -0,0 +1,32 @@
+package org.opentripplanner.ext.carpooling.internal;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.opentripplanner.ext.carpooling.CarpoolingRepository;
+import org.opentripplanner.ext.carpooling.model.CarpoolTrip;
+import org.opentripplanner.transit.model.framework.FeedScopedId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DefaultCarpoolingRepository implements CarpoolingRepository {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DefaultCarpoolingRepository.class);
+
+ private final Map trips = new ConcurrentHashMap<>();
+
+ @Override
+ public Collection getCarpoolTrips() {
+ return trips.values();
+ }
+
+ @Override
+ 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/model/CarpoolLeg.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java
new file mode 100644
index 00000000000..866cacab099
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLeg.java
@@ -0,0 +1,462 @@
+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.FareOffer;
+import org.opentripplanner.model.plan.Emission;
+import org.opentripplanner.model.plan.Leg;
+import org.opentripplanner.model.plan.Place;
+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.network.Route;
+import org.opentripplanner.transit.model.organization.Agency;
+import org.opentripplanner.transit.model.organization.Operator;
+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;
+
+/**
+ * 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 {
+
+ private final ZonedDateTime startTime;
+
+ private final ZonedDateTime endTime;
+
+ private final Set transitAlerts;
+
+ private final int generalizedCost;
+
+ private final Emission emissionPerPerson;
+
+ 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.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 CarpoolLeg}.
+ */
+ 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();
+ }
+
+ @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);
+ }
+
+ @Nullable
+ @Override
+ public Agency agency() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public Operator operator() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Route route() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public TripOnServiceDate tripOnServiceDate() {
+ return Leg.super.tripOnServiceDate();
+ }
+
+ @Nullable
+ @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 ZonedDateTime startTime() {
+ return startTime;
+ }
+
+ @Override
+ 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() {
+ return distanceMeters;
+ }
+
+ @Override
+ 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
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public String routeBrandingUrl() {
+ return Leg.super.routeBrandingUrl();
+ }
+
+ @Override
+ public Place from() {
+ return from;
+ }
+
+ @Override
+ public Place to() {
+ return to;
+ }
+
+ @Override
+ public List listIntermediateStops() {
+ return List.of();
+ }
+
+ @Override
+ public LineString legGeometry() {
+ return geometry;
+ }
+
+ @Nullable
+ @Override
+ public ElevationProfile elevationProfile() {
+ return Leg.super.elevationProfile();
+ }
+
+ @Override
+ public List listWalkSteps() {
+ return Leg.super.listWalkSteps();
+ }
+
+ @Override
+ public Set listStreetNotes() {
+ return Leg.super.listStreetNotes();
+ }
+
+ @Override
+ 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
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public ConstrainedTransfer transferFromPrevLeg() {
+ return Leg.super.transferFromPrevLeg();
+ }
+
+ @Nullable
+ @Override
+ public ConstrainedTransfer transferToNextLeg() {
+ return Leg.super.transferToNextLeg();
+ }
+
+ @Nullable
+ @Override
+ public Integer boardStopPosInPattern() {
+ // TODO CARPOOLING
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Integer alightStopPosInPattern() {
+ // TODO CARPOOLING
+ return null;
+ }
+
+ @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()
+ .withStartTime(startTime.plus(duration))
+ .withEndTime(endTime.plus(duration))
+ .build();
+ }
+
+ @Override
+ public Set fareZones() {
+ return Leg.super.fareZones();
+ }
+
+ @Override
+ public List fareOffers() {
+ return List.of();
+ }
+
+ @Nullable
+ @Override
+ public Emission emissionPerPerson() {
+ return emissionPerPerson;
+ }
+
+ @Nullable
+ @Override
+ 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();
+ }
+
+ public TransitMode mode() {
+ return TransitMode.CARPOOL;
+ }
+
+ /**
+ * Should be used for debug logging only
+ */
+ @Override
+ public String toString() {
+ return ToStringBuilder.of(CarpoolLeg.class)
+ .addObj("from", from())
+ .addObj("to", to())
+ .addTime("startTime", startTime)
+ .addTime("endTime", endTime)
+ .addNum("distance", distanceMeters(), "m")
+ .addNum("cost", generalizedCost)
+ .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/CarpoolLegBuilder.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java
new file mode 100644
index 00000000000..41305b51e6e
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolLegBuilder.java
@@ -0,0 +1,122 @@
+package org.opentripplanner.ext.carpooling.model;
+
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.locationtech.jts.geom.LineString;
+import org.opentripplanner.model.plan.Emission;
+import org.opentripplanner.model.plan.Place;
+import org.opentripplanner.routing.alertpatch.TransitAlert;
+
+public class CarpoolLegBuilder {
+
+ private ZonedDateTime startTime;
+ private ZonedDateTime endTime;
+ private int generalizedCost;
+ private Set transitAlerts = new HashSet<>();
+ private Emission emissionPerPerson;
+ private Place from;
+ private Place to;
+ private LineString geometry;
+ private double distanceMeters;
+
+ CarpoolLegBuilder() {}
+
+ CarpoolLegBuilder(CarpoolLeg original) {
+ startTime = original.startTime();
+ endTime = original.endTime();
+ generalizedCost = original.generalizedCost();
+ transitAlerts = original.listTransitAlerts();
+ emissionPerPerson = original.emissionPerPerson();
+ from = original.from();
+ to = original.to();
+ geometry = original.legGeometry();
+ distanceMeters = original.distanceMeters();
+ }
+
+ public CarpoolLegBuilder withStartTime(ZonedDateTime startTime) {
+ this.startTime = startTime;
+ return this;
+ }
+
+ public ZonedDateTime startTime() {
+ return startTime;
+ }
+
+ public CarpoolLegBuilder withEndTime(ZonedDateTime endTime) {
+ this.endTime = endTime;
+ return this;
+ }
+
+ public ZonedDateTime endTime() {
+ return endTime;
+ }
+
+ public CarpoolLegBuilder withGeneralizedCost(int generalizedCost) {
+ this.generalizedCost = generalizedCost;
+ return this;
+ }
+
+ public int generalizedCost() {
+ return generalizedCost;
+ }
+
+ public CarpoolLegBuilder withAlerts(Collection alerts) {
+ this.transitAlerts = Set.copyOf(alerts);
+ return this;
+ }
+
+ public Set alerts() {
+ return transitAlerts;
+ }
+
+ public CarpoolLegBuilder withEmissionPerPerson(Emission emissionPerPerson) {
+ this.emissionPerPerson = emissionPerPerson;
+ return this;
+ }
+
+ public Emission emissionPerPerson() {
+ return emissionPerPerson;
+ }
+
+ 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/CarpoolStop.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java
new file mode 100644
index 00000000000..b1e70994de9
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolStop.java
@@ -0,0 +1,306 @@
+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 {
+
+ private final AreaStop areaStop;
+ private final CarpoolStopType carpoolStopType;
+ private final int passengerDelta;
+ private final int sequenceNumber;
+ private final ZonedDateTime expectedArrivalTime;
+ private final ZonedDateTime aimedArrivalTime;
+ private final ZonedDateTime expectedDepartureTime;
+ private final ZonedDateTime aimedDepartureTime;
+
+ /**
+ * 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 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 expectedArrivalTime,
+ @Nullable ZonedDateTime aimedArrivalTime,
+ @Nullable ZonedDateTime expectedDepartureTime,
+ @Nullable ZonedDateTime aimedDepartureTime
+ ) {
+ this.areaStop = areaStop;
+ this.carpoolStopType = carpoolStopType;
+ this.passengerDelta = passengerDelta;
+ this.sequenceNumber = sequenceNumber;
+ this.expectedArrivalTime = expectedArrivalTime;
+ this.aimedArrivalTime = aimedArrivalTime;
+ this.expectedDepartureTime = expectedDepartureTime;
+ this.aimedDepartureTime = aimedDepartureTime;
+ }
+
+ // 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 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 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;
+ 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(expectedArrivalTime, other.expectedArrivalTime) &&
+ java.util.Objects.equals(aimedArrivalTime, other.aimedArrivalTime) &&
+ java.util.Objects.equals(expectedDepartureTime, other.expectedDepartureTime) &&
+ java.util.Objects.equals(aimedDepartureTime, other.aimedDepartureTime)
+ );
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hash(
+ areaStop,
+ carpoolStopType,
+ passengerDelta,
+ sequenceNumber,
+ expectedArrivalTime,
+ aimedArrivalTime,
+ expectedDepartureTime,
+ aimedDepartureTime
+ );
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "CarpoolStop{stop=%s, type=%s, delta=%d, seq=%d, arr=%s/%s, dep=%s/%s}",
+ areaStop.getId(),
+ carpoolStopType,
+ passengerDelta,
+ sequenceNumber,
+ expectedArrivalTime,
+ aimedArrivalTime,
+ expectedDepartureTime,
+ aimedDepartureTime
+ );
+ }
+}
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/model/CarpoolTrip.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java
new file mode 100644
index 00000000000..d1e423ea53c
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTrip.java
@@ -0,0 +1,243 @@
+package org.opentripplanner.ext.carpooling.model;
+
+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;
+
+/**
+ * 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
+ *
+ * - 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
+ * 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:
+ *
+ * - Filters check basic compatibility (capacity, timing, direction)
+ * - Insertion strategy finds optimal pickup/dropoff positions along the route
+ * - Validators ensure constraints (capacity timeline, deviation budget) are satisfied
+ *
+ *
+ * @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
+ implements LogInfo {
+
+ private final ZonedDateTime startTime;
+ private final ZonedDateTime endTime;
+ 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;
+
+ // 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.startTime = builder.startTime();
+ this.endTime = builder.endTime();
+ this.provider = builder.provider();
+ this.availableSeats = builder.availableSeats();
+ this.deviationBudget = builder.deviationBudget();
+ this.stops = Collections.unmodifiableList(builder.stops());
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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() {
+ return startTime;
+ }
+
+ public ZonedDateTime endTime() {
+ return endTime;
+ }
+
+ public String provider() {
+ return provider;
+ }
+
+ public Duration deviationBudget() {
+ return deviationBudget;
+ }
+
+ public int availableSeats() {
+ return availableSeats;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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.
+ *
+ * @return a list of coordinates representing the full route of the trip
+ */
+ public List routePoints() {
+ return stops.stream().map(CarpoolStop::getCoordinate).toList();
+ }
+
+ /**
+ * Calculates the number of passengers in the vehicle after visiting the specified position.
+ *
+ * Position semantics:
+ * - Position 0: Before any stops → 0 passengers
+ * - Position N: After Nth stop → cumulative passenger delta up to stop N
+ *
+ * @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()
+ */
+ public int getPassengerCountAtPosition(int position) {
+ if (position < 0) {
+ throw new IllegalArgumentException("Position must be non-negative, got: " + position);
+ }
+
+ if (position > stops.size()) {
+ throw new IllegalArgumentException(
+ "Position " + position + " exceeds valid range (0 to " + stops.size() + ")"
+ );
+ }
+
+ // Position 0 is before any stops
+ if (position == 0) {
+ return 0;
+ }
+
+ // Accumulate passenger deltas up to this position
+ int count = 0;
+ for (int i = 0; i < position; 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
+ @Override
+ public String logName() {
+ return getId().toString();
+ }
+
+ @Override
+ public boolean sameAs(CarpoolTrip other) {
+ return (
+ getId().equals(other.getId()) &&
+ startTime.equals(other.startTime) &&
+ endTime.equals(other.endTime) &&
+ stops.equals(other.stops)
+ );
+ }
+
+ @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..915fe3d0b9f
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/model/CarpoolTripBuilder.java
@@ -0,0 +1,123 @@
+package org.opentripplanner.ext.carpooling.model;
+
+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;
+
+public class CarpoolTripBuilder extends AbstractEntityBuilder {
+
+ private ZonedDateTime startTime;
+ private ZonedDateTime endTime;
+ private String provider;
+
+ private Duration deviationBudget = Duration.ofMinutes(15);
+ private int availableSeats = 1;
+ private List stops = new ArrayList<>();
+
+ public CarpoolTripBuilder(CarpoolTrip original) {
+ super(original);
+ this.startTime = original.startTime();
+ this.endTime = original.endTime();
+ this.provider = original.provider();
+ this.deviationBudget = original.deviationBudget();
+ this.availableSeats = original.availableSeats();
+ this.stops = new ArrayList<>(original.stops());
+ }
+
+ public CarpoolTripBuilder(FeedScopedId id) {
+ super(id);
+ }
+
+ public CarpoolTripBuilder withStartTime(ZonedDateTime startTime) {
+ this.startTime = startTime;
+ return this;
+ }
+
+ public CarpoolTripBuilder withEndTime(ZonedDateTime endTime) {
+ this.endTime = endTime;
+ return this;
+ }
+
+ public CarpoolTripBuilder withProvider(String provider) {
+ this.provider = provider;
+ return this;
+ }
+
+ public CarpoolTripBuilder withDeviationBudget(Duration deviationBudget) {
+ this.deviationBudget = deviationBudget;
+ return this;
+ }
+
+ public CarpoolTripBuilder withAvailableSeats(int availableSeats) {
+ this.availableSeats = availableSeats;
+ return this;
+ }
+
+ public ZonedDateTime startTime() {
+ return startTime;
+ }
+
+ public ZonedDateTime endTime() {
+ return endTime;
+ }
+
+ public String provider() {
+ return provider;
+ }
+
+ public Duration deviationBudget() {
+ return deviationBudget;
+ }
+
+ 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() {
+ 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/routing/CarpoolStreetRouter.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java
new file mode 100644
index 00000000000..e081cd47dd6
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/CarpoolStreetRouter.java
@@ -0,0 +1,207 @@
+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;
+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.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.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;
+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)
+ *
+ *
+ * @see InsertionEvaluator for usage in insertion evaluation
+ */
+public class CarpoolStreetRouter {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CarpoolStreetRouter.class);
+
+ private final StreetLimitationParametersService streetLimitationParametersService;
+ private final RouteRequest request;
+ private final VertexLinker vertexLinker;
+ private final TemporaryVerticesContainer temporaryVerticesContainer;
+
+ /**
+ * Creates a new carpool street router.
+ *
+ * @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(
+ StreetLimitationParametersService streetLimitationParametersService,
+ RouteRequest request,
+ VertexLinker vertexLinker,
+ TemporaryVerticesContainer temporaryVerticesContainer
+ ) {
+ this.streetLimitationParametersService = streetLimitationParametersService;
+ this.request = request;
+ this.vertexLinker = vertexLinker;
+ this.temporaryVerticesContainer = temporaryVerticesContainer;
+ }
+
+ /**
+ * Routes from one location to another using A* street search.
+ *
+ * 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,
+ LinkingContext linkingContext
+ ) {
+ try {
+ var fromVertices = getOrCreateVertices(from, linkingContext);
+ var toVertices = getOrCreateVertices(to, linkingContext);
+
+ return carpoolRouting(
+ new StreetRequest(StreetMode.CAR),
+ fromVertices,
+ toVertices,
+ streetLimitationParametersService.maxCarSpeed()
+ );
+ } catch (Exception e) {
+ LOG.warn("Routing failed from {} to {}: {}", from, to, e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * 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,
+ Set fromVertices,
+ 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/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/InsertionEvaluator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java
new file mode 100644
index 00000000000..e561e539011
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluator.java
@@ -0,0 +1,338 @@
+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;
+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.routing.linking.LinkingContext;
+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 static final Duration INITIAL_ADDITIONAL_DURATION = Duration.ofDays(1);
+
+ private final RoutingFunction routingFunction;
+ private final PassengerDelayConstraints delayConstraints;
+ private final LinkingContext linkingContext;
+
+ /**
+ * 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,
+ LinkingContext linkingContext
+ ) {
+ this.routingFunction = routingFunction;
+ this.delayConstraints = delayConstraints;
+ this.linkingContext = linkingContext;
+ }
+
+ /**
+ * 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, linkingContext);
+ if (segment == null) {
+ LOG.debug("Baseline routing failed for segment {} → {}", i, i + 1);
+ return null;
+ }
+
+ segments[i] = segment;
+ }
+
+ return segments;
+ }
+
+ /**
+ * 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[] cumulativeDurations = calculateCumulativeDurations(baselineSegments);
+
+ InsertionCandidate bestCandidate = null;
+ Duration minAdditionalDuration = INITIAL_ADDITIONAL_DURATION;
+ Duration baselineDuration = cumulativeDurations[cumulativeDurations.length - 1];
+
+ for (InsertionPosition position : viablePositions) {
+ InsertionCandidate candidate = evaluateInsertion(
+ trip,
+ position.pickupPos(),
+ position.dropoffPos(),
+ passengerPickup,
+ passengerDropoff,
+ baselineSegments,
+ cumulativeDurations,
+ 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[] originalCumulativeDurations,
+ Duration baselineDuration
+ ) {
+ // Build modified route segments by reusing cached baseline segments
+ List> modifiedSegments = buildModifiedSegments(
+ trip.routePoints(),
+ baselineSegments,
+ pickupPos,
+ dropoffPos,
+ passengerPickup,
+ passengerDropoff
+ );
+
+ if (modifiedSegments == null) {
+ // Routing failed for new segments
+ return null;
+ }
+
+ // 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(
+ originalCumulativeDurations,
+ calculateCumulativeDurations(
+ modifiedSegments.toArray(new GraphPath[modifiedSegments.size()])
+ ),
+ 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.
+ *
+ * @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, linkingContext);
+ 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;
+ }
+ }
+
+ LOG.trace(
+ "Modified segment {} has no matching baseline segment (endpoints changed)",
+ modifiedIndex
+ );
+ return -1;
+ }
+}
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..2471e39599a
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPosition.java
@@ -0,0 +1,65 @@
+package org.opentripplanner.ext.carpooling.routing;
+
+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;
+ }
+
+ /**
+ * 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..b1cfd754535
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinder.java
@@ -0,0 +1,277 @@
+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.framework.geometry.DirectionUtils;
+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
+ *
+ *
+ * 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
+ ) {
+ List routePoints = trip.routePoints();
+
+ Duration[] beelineTimes = beelineEstimator.calculateCumulativeTimes(routePoints);
+
+ 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(
+ "Insertion at pickup={}, dropoff={} rejected by capacity check",
+ pickupPos,
+ dropoffPos
+ );
+ continue;
+ }
+
+ if (
+ !insertionMaintainsForwardProgress(
+ routePoints,
+ pickupPos,
+ dropoffPos,
+ passengerPickup,
+ passengerDropoff
+ )
+ ) {
+ LOG.trace(
+ "Insertion at pickup={}, dropoff={} rejected by directional check",
+ pickupPos,
+ dropoffPos
+ );
+ continue;
+ }
+
+ 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;
+ }
+ }
+
+ 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
+ ) {
+ if (pickupPos > 0 && pickupPos < routePoints.size()) {
+ WgsCoordinate prevPoint = routePoints.get(pickupPos - 1);
+ WgsCoordinate nextPoint = routePoints.get(pickupPos);
+
+ if (!maintainsForwardProgress(prevPoint, passengerPickup, nextPoint)) {
+ return false;
+ }
+ }
+
+ if (dropoffPos > 0 && dropoffPos <= routePoints.size()) {
+ WgsCoordinate prevPoint;
+ if (dropoffPos == pickupPos) {
+ prevPoint = passengerPickup;
+ } else if (dropoffPos - 1 < routePoints.size()) {
+ prevPoint = routePoints.get(dropoffPos - 1);
+ } else {
+ return true;
+ }
+
+ if (dropoffPos < routePoints.size()) {
+ WgsCoordinate nextPoint = routePoints.get(dropoffPos);
+
+ return maintainsForwardProgress(prevPoint, passengerDropoff, nextPoint);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if inserting a new point maintains forward progress.
+ */
+ private boolean maintainsForwardProgress(
+ WgsCoordinate previous,
+ 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 = DirectionUtils.getAzimuth(
+ previous.asJtsCoordinate(),
+ next.asJtsCoordinate()
+ );
+
+ // Calculate detour directions
+ double bearingToNew = DirectionUtils.getAzimuth(
+ previous.asJtsCoordinate(),
+ newPoint.asJtsCoordinate()
+ );
+ double bearingFromNew = DirectionUtils.getAzimuth(
+ newPoint.asJtsCoordinate(),
+ next.asJtsCoordinate()
+ );
+
+ // Check deviations
+ double deviationToNew = DirectionUtils.bearingDifference(intendedBearing, bearingToNew);
+ double deviationFromNew = DirectionUtils.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;
+ }
+ }
+
+ return true;
+ }
+}
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..78fefd3a5ff
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/routing/RoutingFunction.java
@@ -0,0 +1,20 @@
+package org.opentripplanner.ext.carpooling.routing;
+
+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;
+
+/**
+ * Functional interface for street routing.
+ */
+@FunctionalInterface
+public interface RoutingFunction {
+ 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
new file mode 100644
index 00000000000..0a756f354cf
--- /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.time.Duration;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+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;
+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;
+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.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;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Default implementation of {@link CarpoolingService} that orchestrates the two-phase
+ * carpooling routing algorithm: position finding and insertion evaluation.
+ *
+ * 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:
+ *
+ * - Pre-filtering ({@link FilterChain}): Quickly eliminates incompatible
+ * trips based on capacity, time windows, direction, and distance.
+ * - 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.
+ * - 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.
+ *
+ *
+ * Component Dependencies
+ *
+ * - {@link CarpoolingRepository}: Source of available driver trips
+ * - {@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 {
+
+ 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 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.
+ *
+ * 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 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,
+ StreetLimitationParametersService streetLimitationParametersService,
+ TransitService transitService,
+ VertexLinker vertexLinker
+ ) {
+ this.repository = repository;
+ 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, LinkingContext linkingContext)
+ throws RoutingValidationException {
+ validateRequest(request);
+
+ WgsCoordinate passengerPickup = new WgsCoordinate(request.from().getCoordinate());
+ WgsCoordinate passengerDropoff = new WgsCoordinate(request.to().getCoordinate());
+ var passengerDepartureTime = request.dateTime();
+ var searchWindow = request.searchWindow() == null
+ ? DEFAULT_SEARCH_WINDOW
+ : request.searchWindow();
+
+ LOG.debug(
+ "Finding carpool itineraries from {} to {} at {}",
+ passengerPickup,
+ passengerDropoff,
+ passengerDepartureTime
+ );
+
+ var allTrips = repository.getCarpoolTrips();
+ LOG.debug("Repository contains {} carpool trips", allTrips.size());
+
+ var candidateTrips = allTrips
+ .stream()
+ .filter(trip ->
+ preFilters.accepts(
+ trip,
+ passengerPickup,
+ passengerDropoff,
+ passengerDepartureTime,
+ searchWindow
+ )
+ )
+ .toList();
+
+ LOG.debug(
+ "{} trips passed pre-filters ({} rejected)",
+ candidateTrips.size(),
+ allTrips.size() - candidateTrips.size()
+ );
+
+ if (candidateTrips.isEmpty()) {
+ return List.of();
+ }
+
+ 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
+ );
+
+ // 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;
+ }
+
+ private void validateRequest(RouteRequest request) throws RoutingValidationException {
+ Objects.requireNonNull(request.from());
+ Objects.requireNonNull(request.to());
+ if (request.from().lat == null || request.from().lng == null) {
+ throw new RoutingValidationException(
+ List.of(new RoutingError(RoutingErrorCode.LOCATION_NOT_FOUND, InputField.FROM_PLACE))
+ );
+ }
+ 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
new file mode 100644
index 00000000000..46c97c848a5
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/CarpoolSiriMapper.java
@@ -0,0 +1,280 @@
+package org.opentripplanner.ext.carpooling.updater;
+
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+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.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;
+import org.opentripplanner.transit.model.framework.FeedScopedId;
+import org.opentripplanner.transit.model.site.AreaStop;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import uk.org.siri.siri21.EstimatedCall;
+import uk.org.siri.siri21.EstimatedVehicleJourney;
+
+public class CarpoolSiriMapper {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CarpoolSiriMapper.class);
+ private static final String FEED_ID = "ENT";
+ 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) {
+ throw new IllegalArgumentException(
+ "Carpool trips must have at least 2 stops (origin and destination)."
+ );
+ }
+
+ var tripId = journey.getEstimatedVehicleJourneyCode();
+
+ validateEstimatedCallOrder(calls);
+
+ List stops = new ArrayList<>();
+
+ for (int i = 0; i < calls.size(); i++) {
+ EstimatedCall call = calls.get(i);
+ boolean isFirst = (i == 0);
+ boolean isLast = (i == calls.size() - 1);
+
+ CarpoolStop stop = buildCarpoolStopForPosition(call, tripId, i, isFirst, isLast);
+ stops.add(stop);
+ }
+
+ // Extract start/end times from first/last stops
+ CarpoolStop firstStop = stops.getFirst();
+ CarpoolStop lastStop = stops.getLast();
+
+ ZonedDateTime startTime = firstStop.getExpectedDepartureTime() != null
+ ? firstStop.getExpectedDepartureTime()
+ : firstStop.getAimedDepartureTime();
+
+ ZonedDateTime endTime = lastStop.getExpectedArrivalTime() != null
+ ? lastStop.getExpectedArrivalTime()
+ : lastStop.getAimedArrivalTime();
+
+ return new CarpoolTripBuilder(new FeedScopedId(FEED_ID, tripId))
+ .withStartTime(startTime)
+ .withEndTime(endTime)
+ .withProvider(journey.getOperatorRef().getValue())
+ // TODO: Find a better way to exchange deviation budget with providers.
+ .withDeviationBudget(DEFAULT_DEVIATION_BUDGET)
+ // TODO: Make available seats dynamic based on EstimatedVehicleJourney data
+ .withAvailableSeats(DEFAULT_AVAILABLE_SEATS)
+ .withStops(stops)
+ .build();
+ }
+
+ /**
+ * 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
+ * @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 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
+ CarpoolStopType stopType;
+ int passengerDelta;
+
+ if (isFirst) {
+ // Origin: PICKUP_ONLY, no passengers initially, only departure times
+ stopType = CarpoolStopType.PICKUP_ONLY;
+ passengerDelta = 0;
+ expectedArrivalTime = null;
+ aimedArrivalTime = null;
+ } else if (isLast) {
+ // Destination: DROP_OFF_ONLY, no passengers remain, only arrival times
+ stopType = 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,
+ expectedArrivalTime,
+ aimedArrivalTime,
+ expectedDepartureTime,
+ aimedDepartureTime
+ );
+ }
+
+ /**
+ * Determine the carpool stop type from the EstimatedCall data.
+ */
+ 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 CarpoolStopType.PICKUP_AND_DROP_OFF;
+ } else if (hasDeparture) {
+ return CarpoolStopType.PICKUP_ONLY;
+ } else if (hasArrival) {
+ return CarpoolStopType.DROP_OFF_ONLY;
+ } else {
+ return CarpoolStopType.PICKUP_AND_DROP_OFF;
+ }
+ }
+
+ /**
+ * Calculate the passenger delta (change in passenger count) from the EstimatedCall.
+ */
+ 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 == CarpoolStopType.DROP_OFF_ONLY) {
+ // Assume 1 passenger drop-off
+ return -1;
+ } else if (stopType == CarpoolStopType.PICKUP_ONLY) {
+ // Assume 1 passenger pickup
+ return 1;
+ } else {
+ // No net change for both pickup and drop-off
+ return 0;
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ 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");
+ 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.isEmpty()) {
+ stopAssignments = call.getArrivalStopAssignments();
+ }
+
+ 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(PolygonType gmlPolygon) {
+ var abstractRing = gmlPolygon.getExterior().getAbstractRing().getValue();
+
+ if (!(abstractRing instanceof LinearRingType linearRing)) {
+ throw new IllegalArgumentException("Expected LinearRingType for polygon exterior");
+ }
+
+ 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
new file mode 100644
index 00000000000..4e8532f1799
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/updater/SiriETCarpoolingUpdater.java
@@ -0,0 +1,135 @@
+package org.opentripplanner.ext.carpooling.updater;
+
+import java.util.List;
+import org.opentripplanner.ext.carpooling.CarpoolingRepository;
+import org.opentripplanner.updater.spi.PollingGraphUpdater;
+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;
+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;
+
+/**
+ * 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);
+ private final EstimatedTimetableSource updateSource;
+
+ private final CarpoolingRepository repository;
+ private final CarpoolSiriMapper mapper;
+
+ public SiriETCarpoolingUpdater(
+ DefaultSiriETUpdaterParameters config,
+ CarpoolingRepository repository
+ ) {
+ super(config);
+ this.updateSource = new SiriETHttpTripUpdateSource(config, siriLoader(config));
+ this.repository = repository;
+ this.blockReadinessUntilInitialized = config.blockReadinessUntilInitialized();
+
+ LOG.info("Creating SIRI-ET updater running every {}: {}", pollingPeriod(), updateSource);
+
+ this.mapper = new CarpoolSiriMapper();
+ }
+
+ /**
+ * Repeatedly makes blocking calls to an UpdateStreamer to retrieve carpooling trip updates.
+ */
+ @Override
+ public void runPolling() {
+ boolean moreData;
+ do {
+ 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);
+ }
+ }
+ }
+
+ /**
+ * 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
+ public String toString() {
+ return ToStringBuilder.of(SiriETCarpoolingUpdater.class)
+ .addStr("source", updateSource.toString())
+ .addDuration("frequency", pollingPeriod())
+ .toString();
+ }
+
+ private static SiriLoader siriLoader(DefaultSiriETUpdaterParameters 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/util/BeelineEstimator.java b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java
new file mode 100644
index 00000000000..3160340b74f
--- /dev/null
+++ b/application/src/ext/java/org/opentripplanner/ext/carpooling/util/BeelineEstimator.java
@@ -0,0 +1,109 @@
+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 (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.
+ */
+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 speed;
+
+ /**
+ * 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 speed Average travel speed in meters per second
+ */
+ public BeelineEstimator(double detourFactor, double speed) {
+ if (detourFactor < 1.0) {
+ throw new IllegalArgumentException("detourFactor must be >= 1.0 (got " + detourFactor + ")");
+ }
+ if (speed <= 0) {
+ throw new IllegalArgumentException("speedMps must be positive (got " + speed + ")");
+ }
+ this.detourFactor = detourFactor;
+ this.speed = speed;
+ }
+
+ public double getDetourFactor() {
+ return detourFactor;
+ }
+
+ public double getSpeed() {
+ return speed;
+ }
+
+ /**
+ * 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 / speed;
+ 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;
+ }
+}
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/ext/java/org/opentripplanner/ext/flex/FlexRouter.java b/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java
index 910c6a04aac..28655f164f2 100644
--- a/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java
+++ b/application/src/ext/java/org/opentripplanner/ext/flex/FlexRouter.java
@@ -69,13 +69,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..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
@@ -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,9 @@ public DataFetcher mode() {
if (leg instanceof TransitLeg s) {
return s.mode().name();
}
+ if (leg instanceof CarpoolLeg cl) {
+ return cl.mode().name();
+ }
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 aa45e9a7af2..26af9eeffaa 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 f86c7924942..de8baf2cd6d 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;
@@ -512,6 +513,9 @@ private static Object onLeg(
if (leg instanceof TransitLeg tl) {
return transitLegAccessor.apply(tl);
}
+ if (leg instanceof CarpoolLeg cl) {
+ return cl.mode();
+ }
throw new IllegalStateException("Unhandled leg type: " + leg);
}
}
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 a1b303fc65e..2a0d40a6c24 100644
--- a/application/src/main/java/org/opentripplanner/framework/application/OTPFeature.java
+++ b/application/src/main/java/org/opentripplanner/framework/application/OTPFeature.java
@@ -117,6 +117,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/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/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:
+ *
+ * - Projects the point onto the infinite line passing through segmentStart and segmentEnd
+ * - Clamps the projection to stay within the segment [segmentStart, segmentEnd]
+ * - Calculates the spherical distance from the point to the closest point on the segment
+ *
+ *
+ * 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/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 de70717507f..80682495c44 100644
--- a/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java
+++ b/application/src/main/java/org/opentripplanner/routing/algorithm/RoutingWorker.java
@@ -113,13 +113,14 @@ public RoutingResponse route() {
var r1 = CompletableFuture.supplyAsync(() -> routeDirectStreet());
var r2 = CompletableFuture.supplyAsync(() -> routeDirectFlex());
var r3 = CompletableFuture.supplyAsync(() -> routeTransit());
+ var r4 = CompletableFuture.supplyAsync(() -> 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());
}
} catch (RoutingValidationException e) {
result.merge(RoutingResult.failed(e.getRoutingErrors()));
@@ -270,6 +271,20 @@ private RoutingResult routeDirectFlex() {
}
}
+ private RoutingResult routeCarpooling() {
+ if (OTPFeature.CarPooling.isOff()) {
+ return RoutingResult.ok(List.of());
+ }
+ debugTimingAggregator.startedDirectCarpoolRouter();
+ try {
+ return RoutingResult.ok(serverContext.carpoolingService().route(request, linkingContext()));
+ } catch (RoutingValidationException e) {
+ return RoutingResult.failed(e.getRoutingErrors());
+ } finally {
+ debugTimingAggregator.finishedDirectCarpoolRouter();
+ }
+ }
+
private RoutingResult routeTransit() {
debugTimingAggregator.startedTransitRouting();
try {
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/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java b/application/src/main/java/org/opentripplanner/standalone/api/OtpServerRequestContext.java
index 2da05d948d8..d3facc30c0f 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,9 @@ default GraphFinder graphFinder() {
/* Sandbox modules */
+ @Nullable
+ CarpoolingService carpoolingService();
+
@Nullable
default List listExtensionRequestContexts(RouteRequest request) {
var list = new ArrayList();
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 426219d7461..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
@@ -52,7 +52,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;
@@ -176,10 +176,15 @@ public List getVehiclePositionsUpdaterParamet
}
@Override
- public List getSiriETUpdaterParameters() {
+ 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);
@@ -241,6 +246,7 @@ public enum Type {
REAL_TIME_ALERTS(GtfsRealtimeAlertsUpdaterConfig::create),
VEHICLE_POSITIONS(VehiclePositionsUpdaterConfig::create),
SIRI_ET_UPDATER(SiriETUpdaterConfig::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/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/standalone/configure/ConstructApplication.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java
index a9c7a7f9f10..b4a777a713e 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,6 +193,7 @@ private void setupTransitRoutingServer() {
vehicleRentalRepository(),
vehicleParkingRepository(),
timetableRepository(),
+ carpoolingRepository(),
snapshotManager(),
routerConfig().updaterConfig()
);
@@ -267,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 ba5cc1e2611..7e08a9619a6 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,9 @@
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;
import org.opentripplanner.ext.emission.configure.EmissionServiceModule;
import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayRepository;
@@ -65,6 +68,7 @@
@Singleton
@Component(
modules = {
+ CarpoolingModule.class,
ConfigModule.class,
ConstructApplicationModule.class,
EmissionServiceModule.class,
@@ -106,6 +110,12 @@ public interface ConstructApplicationFactory {
TimetableSnapshotManager timetableSnapshotManager();
DataImportIssueSummary dataImportIssueSummary();
+ @Nullable
+ CarpoolingService carpoolingService();
+
+ @Nullable
+ CarpoolingRepository carpoolingRepository();
+
@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 28b0c61681c..19029245604 100644
--- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java
+++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplicationModule.java
@@ -10,6 +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.carpooling.CarpoolingService;
import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService;
import org.opentripplanner.ext.geocoder.LuceneIndex;
import org.opentripplanner.ext.interactivelauncher.api.LauncherRequestDecorator;
@@ -56,6 +57,7 @@ OtpServerRequestContext providesServerContext(
VehicleParkingService vehicleParkingService,
List rideHailingServices,
ViaCoordinateTransferFactory viaTransferResolver,
+ @Nullable CarpoolingService carpoolingService,
@Nullable StopConsolidationService stopConsolidationService,
StreetLimitationParametersService streetLimitationParametersService,
@Nullable TraverseVisitor, ?> traverseVisitor,
@@ -100,6 +102,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 b1af36c33f7..48be234cde5 100644
--- a/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java
+++ b/application/src/main/java/org/opentripplanner/standalone/server/DefaultServerRequestContext.java
@@ -9,6 +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.carpooling.CarpoolingService;
import org.opentripplanner.ext.empiricaldelay.EmpiricalDelayService;
import org.opentripplanner.ext.flex.FlexParameters;
import org.opentripplanner.ext.geocoder.LuceneIndex;
@@ -68,6 +69,9 @@ public class DefaultServerRequestContext implements OtpServerRequestContext {
/* Optional fields */
+ @Nullable
+ private final CarpoolingService carpoolingService;
+
@Nullable
private final ItineraryDecorator emissionItineraryDecorator;
@@ -131,6 +135,7 @@ public DefaultServerRequestContext(
VertexLinker vertexLinker,
ViaCoordinateTransferFactory viaTransferResolver,
WorldEnvelopeService worldEnvelopeService,
+ @Nullable CarpoolingService carpoolingService,
@Nullable ItineraryDecorator emissionItineraryDecorator,
@Nullable EmpiricalDelayService empiricalDelayService,
@Nullable LuceneIndex luceneIndex,
@@ -165,6 +170,7 @@ public DefaultServerRequestContext(
this.worldEnvelopeService = worldEnvelopeService;
// Optional fields
+ this.carpoolingService = carpoolingService;
this.emissionItineraryDecorator = emissionItineraryDecorator;
this.empiricalDelayService = empiricalDelayService;
this.luceneIndex = luceneIndex;
@@ -302,6 +308,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 a16c73d23cf..c91800c929d 100644
--- a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java
+++ b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java
@@ -10,7 +10,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;
@@ -30,7 +30,7 @@ public interface UpdatersParameters {
List getVehiclePositionsUpdaterParameters();
- List getSiriETUpdaterParameters();
+ List getSiriETUpdaterParameters();
List getSiriETGooglePubsubUpdaterParameters();
@@ -48,5 +48,7 @@ public interface UpdatersParameters {
List getSiriAzureSXUpdaterParameters();
+ List getSiriETCarpoolingUpdaterParameters();
+
List getMqttSiriETUpdaterParameters();
}
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..e7b0dee65fb 100644
--- a/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java
+++ b/application/src/main/java/org/opentripplanner/updater/configure/SiriUpdaterModule.java
@@ -12,6 +12,7 @@
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;
@@ -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,14 +39,14 @@ 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 SiriETUpdaterParameters p -> new SiriETHttpTripUpdateSource(
- p.sourceParameters(),
+ case DefaultSiriETUpdaterParameters p -> new SiriETHttpTripUpdateSource(
+ p,
createLoader(params)
);
case SiriETLiteUpdaterParameters p -> new SiriETLiteHttpTripUpdateSource(
- p.sourceParameters(),
+ p,
createLoader(params)
);
default -> throw new IllegalArgumentException("Unexpected value: " + params);
@@ -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());
@@ -81,7 +82,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(),
@@ -97,9 +98,9 @@ private static SiriLoader createLoader(SiriETUpdater.Parameters params) {
}
}
- private static Consumer createMetricsConsumer(SiriETUpdater.Parameters params) {
+ private static Consumer createMetricsConsumer(SiriETUpdaterParameters 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/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java
index 73cb6c32748..52db268c596 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.siri.updater.mqtt.SiriETMqttUpdater;
import org.opentripplanner.ext.vehiclerentalservicedirectory.VehicleRentalServiceDirectoryFetcher;
@@ -52,6 +54,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;
@@ -62,6 +65,7 @@ private UpdaterConfigurator(
VehicleRentalRepository vehicleRentalRepository,
VehicleParkingRepository parkingRepository,
TimetableRepository timetableRepository,
+ CarpoolingRepository carpoolingRepository,
TimetableSnapshotManager snapshotManager,
UpdatersParameters updatersParameters
) {
@@ -73,6 +77,7 @@ private UpdaterConfigurator(
this.updatersParameters = updatersParameters;
this.parkingRepository = parkingRepository;
this.snapshotManager = snapshotManager;
+ this.carpoolingRepository = carpoolingRepository;
}
public static void configure(
@@ -82,6 +87,7 @@ public static void configure(
VehicleRentalRepository vehicleRentalRepository,
VehicleParkingRepository parkingRepository,
TimetableRepository timetableRepository,
+ CarpoolingRepository carpoolingRepository,
TimetableSnapshotManager snapshotManager,
UpdatersParameters updatersParameters
) {
@@ -92,6 +98,7 @@ public static void configure(
vehicleRentalRepository,
parkingRepository,
timetableRepository,
+ carpoolingRepository,
snapshotManager,
updatersParameters
).configure();
@@ -186,6 +193,9 @@ private List createUpdatersFromConfig() {
for (var configItem : updatersParameters.getSiriETUpdaterParameters()) {
updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter()));
}
+ for (var configItem : updatersParameters.getSiriETCarpoolingUpdaterParameters()) {
+ updaters.add(new SiriETCarpoolingUpdater(configItem, carpoolingRepository));
+ }
for (var configItem : updatersParameters.getSiriETLiteUpdaterParameters()) {
updaters.add(SiriUpdaterModule.createSiriETUpdater(configItem, provideSiriAdapter()));
}
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
new file mode 100644
index 00000000000..2dad44d3d78
--- /dev/null
+++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/updater/DefaultSiriETUpdaterParameters.java
@@ -0,0 +1,19 @@
+package org.opentripplanner.updater.trip.siri.updater;
+
+import java.time.Duration;
+import org.opentripplanner.updater.spi.HttpHeaders;
+
+public record DefaultSiriETUpdaterParameters(
+ String configRef,
+ String feedId,
+ boolean blockReadinessUntilInitialized,
+ String url,
+ Duration frequency,
+ String requestorRef,
+ Duration timeout,
+ Duration previewInterval,
+ boolean fuzzyTripMatching,
+ HttpHeaders httpRequestHeaders,
+ boolean producerMetrics
+)
+ 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
index d4eb5630de8..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
@@ -1,23 +1,13 @@
package org.opentripplanner.updater.trip.siri.updater;
-import java.time.Duration;
-import org.opentripplanner.updater.spi.HttpHeaders;
+import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters;
+import org.opentripplanner.updater.trip.UrlUpdaterParameters;
-public record SiriETUpdaterParameters(
- 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;
- }
+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 8ab33c66ca9..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,11 +14,7 @@ public record SiriETLiteUpdaterParameters(
boolean fuzzyTripMatching,
HttpHeaders httpRequestHeaders
)
- implements SiriETUpdater.Parameters, SiriETLiteHttpTripUpdateSource.Parameters {
- public SiriETLiteHttpTripUpdateSource.Parameters sourceParameters() {
- return this;
- }
-
+ implements SiriETUpdaterParameters, SiriETLiteHttpTripUpdateSource.Parameters {
@Override
public String url() {
return uri.toString();
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 90ff351d5e1..773fada765b 100644
--- a/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql
+++ b/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql
@@ -1732,6 +1732,7 @@ enum Mode {
bus
cableway
car
+ carpool
coach
foot
funicular
diff --git a/application/src/test/java/org/opentripplanner/TestServerContext.java b/application/src/test/java/org/opentripplanner/TestServerContext.java
index e540c289a67..f4d938517a4 100644
--- a/application/src/test/java/org/opentripplanner/TestServerContext.java
+++ b/application/src/test/java/org/opentripplanner/TestServerContext.java
@@ -120,6 +120,7 @@ public static OtpServerRequestContext createServerContext(
vertexLinker,
createViaTransferResolver(graph, transitService),
createWorldEnvelopeService(),
+ null,
createEmissionsItineraryDecorator(),
null,
null,
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/CarpoolTestCoordinates.java b/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolTestCoordinates.java
new file mode 100644
index 00000000000..f2b527f137c
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/CarpoolTestCoordinates.java
@@ -0,0 +1,36 @@
+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 CarpoolTestCoordinates {
+
+ // Base coordinates (Oslo area)
+ public static final WgsCoordinate OSLO_CENTER = new WgsCoordinate(59.9139, 10.7522);
+ // ~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);
+ 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_NORTHWEST = new WgsCoordinate(59.9439, 10.7122);
+
+ 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
new file mode 100644
index 00000000000..4b95add5c69
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/TestCarpoolTripBuilder.java
@@ -0,0 +1,250 @@
+package org.opentripplanner.ext.carpooling;
+
+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;
+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;
+
+/**
+ * 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 origin and destination stops, default capacity of 4.
+ */
+ public static CarpoolTrip createSimpleTrip(WgsCoordinate boarding, WgsCoordinate alighting) {
+ var origin = createOriginStop(boarding);
+ var destination = createDestinationStop(alighting, 1);
+ return createTripWithCapacity(4, List.of(origin, destination));
+ }
+
+ /**
+ * Creates a simple trip with specific departure time.
+ */
+ public static CarpoolTrip createSimpleTripWithTime(
+ WgsCoordinate boarding,
+ WgsCoordinate alighting,
+ ZonedDateTime startTime
+ ) {
+ 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 origin, intermediate stops, and destination.
+ */
+ public static CarpoolTrip createTripWithStops(
+ WgsCoordinate boarding,
+ List intermediateStops,
+ WgsCoordinate 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 and all stops (including origin/destination).
+ */
+ public static CarpoolTrip createTripWithCapacity(int seats, List stops) {
+ return createTripWithDeviationBudget(Duration.ofMinutes(10), seats, stops);
+ }
+
+ /**
+ * Creates a trip with specified deviation budget.
+ */
+ public static CarpoolTrip createTripWithDeviationBudget(
+ Duration deviationBudget,
+ WgsCoordinate boarding,
+ WgsCoordinate alighting
+ ) {
+ var origin = createOriginStop(boarding);
+ var destination = createDestinationStop(alighting, 1);
+ return createTripWithDeviationBudget(deviationBudget, 4, List.of(origin, destination));
+ }
+
+ /**
+ * Creates a trip with all parameters specified.
+ */
+ public static CarpoolTrip createTripWithDeviationBudget(
+ Duration deviationBudget,
+ int seats,
+ List stops
+ ) {
+ return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder(
+ org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable(
+ "TEST",
+ "trip-" + idCounter.incrementAndGet()
+ )
+ )
+ .withStops(stops)
+ .withAvailableSeats(seats)
+ .withStartTime(ZonedDateTime.now())
+ .withDeviationBudget(deviationBudget)
+ .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,
+ List stops
+ ) {
+ return new org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder(
+ org.opentripplanner.transit.model.framework.FeedScopedId.ofNullable(
+ "TEST",
+ "trip-" + idCounter.incrementAndGet()
+ )
+ )
+ .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.
+ */
+ public static CarpoolStop createStop(int zeroBasedSequence, int passengerDelta) {
+ return createStopAt(zeroBasedSequence, passengerDelta, CarpoolTestCoordinates.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),
+ 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),
+ 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),
+ CarpoolStopType.DROP_OFF_ONLY,
+ 0,
+ sequenceNumber,
+ expectedArrivalTime,
+ aimedArrivalTime,
+ null,
+ 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/constraints/PassengerDelayConstraintsTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java
new file mode 100644
index 00000000000..8364132d2e5
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/constraints/PassengerDelayConstraintsTest.java
@@ -0,0 +1,470 @@
+package org.opentripplanner.ext.carpooling.constraints;
+
+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;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opentripplanner.astar.model.GraphPath;
+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;
+
+class PassengerDelayConstraintsTest {
+
+ private PassengerDelayConstraints constraints;
+
+ @BeforeEach
+ void setup() {
+ constraints = new PassengerDelayConstraints();
+ }
+
+ @Test
+ void satisfiesConstraints_noExistingStops_alwaysAccepts() {
+ Duration[] originalTimes = { Duration.ZERO, Duration.ofMinutes(10) };
+
+ // Modified route with passenger inserted
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(7)),
+ };
+
+ // Should accept - no existing passengers to protect
+ assertTrue(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(modifiedSegments),
+ 1,
+ 2
+ )
+ );
+ }
+
+ @Test
+ void satisfiesConstraints_delayWellUnderThreshold_accepts() {
+ // 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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(3)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(4)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertTrue(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(10)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertTrue(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(11)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertFalse(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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 ✗
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ 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(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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 ✓
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ 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(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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 ✓
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ 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(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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 ✓
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ 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(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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 ✓
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ 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(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(13)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertTrue(
+ customConstraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(8)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertFalse(
+ customConstraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5).plusSeconds(1)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertFalse(
+ strictConstraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(35)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertTrue(
+ permissiveConstraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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)
+ GraphPath[] modifiedSegments = new GraphPath[] {
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(4)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(6)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ CarpoolGraphPathBuilder.createGraphPath(Duration.ofMinutes(5)),
+ };
+
+ assertTrue(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(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
+ // 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[] {
+ 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(
+ constraints.satisfiesConstraints(
+ originalTimes,
+ calculateCumulativeDurations(modifiedSegments),
+ 3,
+ 4
+ )
+ );
+ }
+}
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..5b899bebf30
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/CapacityFilterTest.java
@@ -0,0 +1,105 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+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.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;
+import org.junit.jupiter.api.Test;
+
+class CapacityFilterTest {
+
+ private CapacityFilter filter;
+
+ @BeforeEach
+ void setup() {
+ filter = new CapacityFilter();
+ }
+
+ @Test
+ void accepts_tripWithCapacity_returnsTrue() {
+ var trip = createTripWithStops(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
+ // All 4 seats taken
+ var stop1 = createStop(0, 4);
+ 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));
+ }
+
+ @Test
+ void accepts_tripWithOneOpenSeat_returnsTrue() {
+ // 3 of 4 seats taken
+ var stop1 = createStop(0, 3);
+ var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH);
+
+ assertTrue(filter.accepts(trip, OSLO_EAST, OSLO_WEST));
+ }
+
+ @Test
+ void accepts_zeroCapacityTrip_returnsFalse() {
+ var stops = List.of(createOriginStop(OSLO_CENTER), createDestinationStop(OSLO_NORTH, 1));
+ var trip = createTripWithCapacity(0, stops);
+
+ assertFalse(filter.accepts(trip, OSLO_EAST, OSLO_WEST));
+ }
+
+ @Test
+ void accepts_passengerCoordinatesIgnored() {
+ // Filter only checks if ANY capacity exists, not position-specific
+ 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));
+ assertTrue(filter.accepts(trip, OSLO_NORTH, OSLO_SOUTH));
+ }
+
+ @Test
+ void accepts_tripWithFluctuatingCapacity_checksOverallAvailability() {
+ // 2 passengers
+ var stop1 = createStop(0, 2);
+ // Dropoff 2
+ var stop2 = createStop(1, -2);
+ // Pickup 1
+ var stop3 = createStop(2, 1);
+ 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));
+ }
+
+ @Test
+ void accepts_tripAlwaysAtCapacity_returnsTrue() {
+ // CapacityFilter only checks configured capacity, not actual occupancy
+ // 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 = 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
+ 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..962a58b6e47
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DirectionalCompatibilityFilterTest.java
@@ -0,0 +1,185 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+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;
+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() {
+ // Trip goes north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger also going north
+ var passengerPickup = OSLO_EAST;
+ // Northeast
+ var passengerDropoff = new WgsCoordinate(59.9549, 10.7922);
+
+ assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff));
+ }
+
+ @Test
+ void accepts_passengerOppositeDirection_returnsFalse() {
+ // Trip goes north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_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)
+ // 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));
+ }
+
+ @Test
+ void accepts_passengerFarFromRoute_butDirectionallyAligned_returnsTrue() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger far to the east but directionally aligned (both going north)
+ // 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)
+ assertTrue(filter.accepts(trip, passengerPickup, passengerDropoff));
+ }
+
+ @Test
+ void accepts_passengerPartiallyAligned_withinTolerance_returnsTrue() {
+ // Going north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger going northeast (~45° off)
+ // Should accept within default tolerance (60°)
+ assertTrue(filter.accepts(trip, OSLO_CENTER, OSLO_NORTHEAST));
+ }
+
+ @Test
+ void accepts_passengerPerpendicularToTrip_returnsFalse() {
+ // Going north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger going east (90° perpendicular)
+ // Should reject (exceeds 60° tolerance)
+ assertFalse(filter.accepts(trip, OSLO_CENTER, OSLO_EAST));
+ }
+
+ @Test
+ void accepts_complexRoute_multipleSegments_findsCompatibleSegment() {
+ // Trip with multiple segments going different directions
+ // 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)
+ 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() {
+ // Going north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger nearby but going opposite direction
+ // 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));
+ }
+
+ @Test
+ void customBearingTolerance_acceptsWithinCustomTolerance() {
+ // Custom filter with 90° tolerance (very permissive)
+ var customFilter = new DirectionalCompatibilityFilter(90.0);
+
+ // Going north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger going east (90° perpendicular)
+ // Should accept with 90° tolerance (default 60° would reject)
+ assertTrue(customFilter.accepts(trip, OSLO_CENTER, OSLO_EAST));
+ }
+
+ @Test
+ void customBearingTolerance_rejectsOutsideCustomTolerance() {
+ // Custom filter with 30° tolerance (strict)
+ var customFilter = new DirectionalCompatibilityFilter(30.0);
+
+ // Going north
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Passenger going northeast (~45° off)
+ // Should reject with 30° tolerance (default 60° would accept)
+ assertFalse(customFilter.accepts(trip, OSLO_CENTER, OSLO_NORTHEAST));
+ }
+
+ @Test
+ void getBearingToleranceDegrees_returnsConfiguredValue() {
+ var customFilter = new DirectionalCompatibilityFilter(45.0);
+ assertEquals(45.0, customFilter.getBearingToleranceDegrees());
+ }
+
+ @Test
+ void defaultBearingTolerance_is60Degrees() {
+ assertEquals(60.0, filter.getBearingToleranceDegrees());
+ }
+}
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..d493eb6f599
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/DistanceBasedFilterTest.java
@@ -0,0 +1,257 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+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;
+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
+ // 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));
+ }
+
+ @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_returnsTrue() {
+ // 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
+ // 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));
+ }
+
+ @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
+ // 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));
+ }
+
+ @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_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 LAKE_SOUTH to LAKE_WEST segment
+ // 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));
+ }
+
+ @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
+ // Very close
+ var passengerPickup = new WgsCoordinate(59.901, 10.751);
+ 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/FilterChainTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java
new file mode 100644
index 00000000000..0b63cfef26f
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/FilterChainTest.java
@@ -0,0 +1,118 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+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_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;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class FilterChainTest {
+
+ @Test
+ void accepts_allFiltersAccept_returnsTrue() {
+ TripFilter filter1 = (trip, pickup, dropoff) -> true;
+ TripFilter filter2 = (trip, pickup, dropoff) -> true;
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ var chain = new FilterChain(List.of(filter1, filter2));
+
+ assertTrue(chain.accepts(trip, OSLO_EAST, OSLO_WEST));
+ }
+
+ @Test
+ void accepts_oneFilterRejects_returnsFalse() {
+ TripFilter filter1 = (trip, pickup, dropoff) -> true;
+ TripFilter filter2 = (trip, pickup, dropoff) -> false;
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ var chain = new FilterChain(List.of(filter1, filter2));
+
+ assertFalse(chain.accepts(trip, OSLO_EAST, OSLO_WEST));
+ }
+
+ @Test
+ void accepts_shortCircuits_afterFirstRejection() {
+ 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);
+
+ var chain = new FilterChain(List.of(filter1, filter2, filter3));
+ chain.accepts(trip, OSLO_EAST, OSLO_WEST);
+
+ assertFalse(filter3Called[0], "Filter3 should not have been called due to short-circuit");
+ }
+
+ @Test
+ void accepts_firstFilterRejects_doesNotCallOthers() {
+ var filter2Called = new boolean[] { 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);
+
+ assertFalse(filter2Called[0], "Filter2 should not have been called due to short-circuit");
+ }
+
+ @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 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));
+ }
+
+ @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() {
+ 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));
+ }
+}
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..cedc95157f9
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/filter/TimeBasedFilterTest.java
@@ -0,0 +1,125 @@
+package org.opentripplanner.ext.carpooling.filter;
+
+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;
+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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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, Duration.ofMinutes(30))
+ );
+ }
+
+ @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));
+ }
+}
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..803f98e4b26
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/model/CarpoolTripCapacityTest.java
@@ -0,0 +1,196 @@
+package org.opentripplanner.ext.carpooling.model;
+
+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;
+
+/**
+ * Tests for capacity checking methods on {@link CarpoolTrip}.
+ */
+class CarpoolTripCapacityTest {
+
+ @Test
+ void getPassengerCountAtPosition_noStops_allZeros() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ // Boarding
+ assertEquals(0, trip.getPassengerCountAtPosition(0));
+ // Beyond stops
+ assertEquals(0, trip.getPassengerCountAtPosition(1));
+ }
+
+ @Test
+ void getPassengerCountAtPosition_onePickupStop_incrementsAtStop() {
+ // Pickup 1 passenger, then drop off 1 passenger
+ var stop1 = createStop(0, 1);
+ var stop2 = createStop(1, -1);
+ var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH);
+
+ // Position 0: Before origin stop
+ assertEquals(0, trip.getPassengerCountAtPosition(0));
+ // 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
+ void getPassengerCountAtPosition_pickupAndDropoff_incrementsThenDecrements() {
+ // Pickup 2 passengers
+ var stop1 = createStop(0, 2);
+ // Dropoff 1 passenger
+ var stop2 = createStop(1, -1);
+ // Dropoff remaining passenger
+ var stop3 = createStop(2, -1);
+ var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2, stop3), OSLO_NORTH);
+
+ // Position 0: Before origin stop
+ assertEquals(0, trip.getPassengerCountAtPosition(0));
+ // 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
+ 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 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));
+ // 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
+ void getPassengerCountAtPosition_negativePosition_throwsException() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ 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);
+
+ // 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 = createTripWithStops(OSLO_CENTER, List.of(), OSLO_NORTH);
+
+ assertTrue(trip.hasCapacityForInsertion(1, 2, 1));
+ // Can fit all 4 seats
+ assertTrue(trip.hasCapacityForInsertion(1, 2, 4));
+ }
+
+ @Test
+ void hasCapacityForInsertion_fullCapacity_noCapacity() {
+ // Fill all 4 seats
+ var stop1 = createStop(0, 4);
+ var trip = createTripWithStops(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() {
+ // 3 of 4 seats taken
+ var stop1 = createStop(0, 3);
+ var trip = createTripWithStops(OSLO_CENTER, List.of(stop1), OSLO_NORTH);
+
+ // 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);
+ // Total 3 passengers at position 3
+ var stop2 = createStop(1, 1);
+ var trip = createTripWithStops(OSLO_CENTER, List.of(stop1, stop2), OSLO_NORTH);
+
+ // 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 = 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
+ assertTrue(trip.hasCapacityForInsertion(1, 1, 4));
+ }
+
+ @Test
+ void hasCapacityForInsertion_capacityFreesUpInRange_checksMaxInRange() {
+ // 3 passengers
+ var stop1 = createStop(0, 3);
+ // 2 dropoff, leaving 1
+ var stop2 = createStop(1, -2);
+ 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
+ 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/InsertionCandidateTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java
new file mode 100644
index 00000000000..5b37df61e25
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionCandidateTest.java
@@ -0,0 +1,244 @@
+package org.opentripplanner.ext.carpooling.routing;
+
+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.TestCarpoolTripBuilder.createSimpleTrip;
+import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithDeviationBudget;
+
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+
+class InsertionCandidateTest {
+
+ @Test
+ void additionalDuration_calculatesCorrectly() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+ // 3 segments
+ var segments = createGraphPaths(3);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 2,
+ segments,
+ Duration.ofMinutes(10),
+ Duration.ofMinutes(15)
+ );
+
+ assertEquals(Duration.ofMinutes(5), candidate.additionalDuration());
+ }
+
+ @Test
+ void additionalDuration_zeroAdditional_returnsZero() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(2);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 2,
+ segments,
+ Duration.ofMinutes(10),
+ // Same as baseline
+ Duration.ofMinutes(10)
+ );
+
+ assertEquals(Duration.ZERO, candidate.additionalDuration());
+ }
+
+ @Test
+ void isWithinDeviationBudget_withinBudget_returnsTrue() {
+ var trip = createTripWithDeviationBudget(Duration.ofMinutes(10), OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(2);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 2,
+ segments,
+ // baseline
+ Duration.ofMinutes(10),
+ // total (8 min additional, within 10 min budget)
+ Duration.ofMinutes(18)
+ );
+
+ assertTrue(candidate.isWithinDeviationBudget());
+ }
+
+ @Test
+ void isWithinDeviationBudget_exceedsBudget_returnsFalse() {
+ var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(2);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 2,
+ segments,
+ // baseline
+ Duration.ofMinutes(10),
+ // total (10 min additional, exceeds 5 min budget)
+ Duration.ofMinutes(20)
+ );
+
+ assertFalse(candidate.isWithinDeviationBudget());
+ }
+
+ @Test
+ void isWithinDeviationBudget_exactlyAtBudget_returnsTrue() {
+ var trip = createTripWithDeviationBudget(Duration.ofMinutes(5), OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(2);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 2,
+ segments,
+ Duration.ofMinutes(10),
+ // Exactly 5 min additional
+ Duration.ofMinutes(15)
+ );
+
+ assertTrue(candidate.isWithinDeviationBudget());
+ }
+
+ @Test
+ void getPickupSegments_returnsCorrectRange() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(5);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 2,
+ 4,
+ segments,
+ Duration.ofMinutes(10),
+ Duration.ofMinutes(15)
+ );
+
+ var pickupSegments = candidate.getPickupSegments();
+ // Segments 0-1 (before position 2)
+ assertEquals(2, pickupSegments.size());
+ assertEquals(segments.subList(0, 2), pickupSegments);
+ }
+
+ @Test
+ void getPickupSegments_positionZero_returnsEmpty() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(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 = createGraphPaths(5);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 3,
+ segments,
+ Duration.ofMinutes(10),
+ Duration.ofMinutes(15)
+ );
+
+ var sharedSegments = candidate.getSharedSegments();
+ // Segments 1-2 (positions 1 to 3)
+ assertEquals(2, sharedSegments.size());
+ assertEquals(segments.subList(1, 3), sharedSegments);
+ }
+
+ @Test
+ void getSharedSegments_adjacentPositions_returnsSingleSegment() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(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 = createGraphPaths(5);
+
+ var candidate = new InsertionCandidate(
+ trip,
+ 1,
+ 3,
+ segments,
+ Duration.ofMinutes(10),
+ Duration.ofMinutes(15)
+ );
+
+ var dropoffSegments = candidate.getDropoffSegments();
+ // Segments 3-4 (after position 3)
+ assertEquals(2, dropoffSegments.size());
+ assertEquals(segments.subList(3, 5), dropoffSegments);
+ }
+
+ @Test
+ void getDropoffSegments_atEnd_returnsEmpty() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+ var segments = createGraphPaths(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 = createGraphPaths(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"));
+ // 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
new file mode 100644
index 00000000000..23977a80c3f
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionEvaluatorTest.java
@@ -0,0 +1,417 @@
+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.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;
+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.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;
+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.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 PassengerDelayConstraints delayConstraints;
+ private InsertionPositionFinder positionFinder;
+
+ @BeforeEach
+ void setup() {
+ delayConstraints = new PassengerDelayConstraints();
+ positionFinder = new InsertionPositionFinder(delayConstraints, new BeelineEstimator());
+ }
+
+ /**
+ * Helper method that mimics the old findOptimalInsertion() behavior for backwards compatibility in tests.
+ * This explicitly performs position finding followed by evaluation.
+ */
+ private InsertionCandidate findOptimalInsertion(
+ CarpoolTrip trip,
+ WgsCoordinate passengerPickup,
+ WgsCoordinate passengerDropoff,
+ RoutingFunction routingFunction
+ ) {
+ List viablePositions = positionFinder.findViablePositions(
+ trip,
+ passengerPickup,
+ passengerDropoff
+ );
+
+ if (viablePositions.isEmpty()) {
+ return null;
+ }
+
+ var evaluator = new InsertionEvaluator(routingFunction, delayConstraints, null);
+ 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, linkingContext) -> null;
+
+ var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction);
+
+ assertNull(result);
+ }
+
+ @Test
+ void findOptimalInsertion_oneValidPosition_returnsCandidate() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ var mockPath = createGraphPath();
+
+ RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath;
+
+ var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction);
+
+ assertNotNull(result);
+ assertEquals(1, result.pickupPosition());
+ assertEquals(2, result.dropoffPosition());
+ }
+
+ @Test
+ void findOptimalInsertion_routingFails_skipsPosition() {
+ // 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);
+
+ var mockPath = createGraphPath(Duration.ofMinutes(5));
+
+ // Routing sequence:
+ // 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)
+ final int[] callCount = { 0 };
+ RoutingFunction routingFunction = (from, to, linkingContext) -> {
+ 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, routingFunction);
+
+ // 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 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));
+
+ RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath;
+
+ var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction);
+
+ // 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);
+
+ var mockPath = createGraphPath();
+
+ RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath;
+
+ assertDoesNotThrow(() ->
+ findOptimalInsertion(trip, OSLO_MIDPOINT_NORTH, OSLO_NORTHEAST, routingFunction)
+ );
+ }
+
+ @Test
+ void findOptimalInsertion_baselineDurationCalculationFails_returnsNull() {
+ var trip = createSimpleTrip(OSLO_CENTER, OSLO_NORTH);
+
+ RoutingFunction routingFunction = (from, to, linkingContext) -> null;
+
+ var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction);
+
+ assertNull(result);
+ }
+
+ @Test
+ void findOptimalInsertion_selectsMinimumAdditionalDuration() {
+ var trip = createTripWithDeviationBudget(Duration.ofMinutes(20), OSLO_CENTER, OSLO_NORTH);
+
+ // Baseline: 1 segment (CENTER → NORTH) at 10 min
+ // The algorithm will try multiple pickup/dropoff positions
+ // 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));
+
+ // Provide consistent route times
+ // Baseline
+ // First insertion (15 min total, 5 min additional)
+ // Second insertion (18 min total, 8 min additional)
+ @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, linkingContext) -> {
+ 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
+ // 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);
+
+ var mockPath = createGraphPath();
+
+ RoutingFunction routingFunction = (from, to, linkingContext) -> mockPath;
+
+ var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction);
+
+ 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 = 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 = createGraphPath(Duration.ofMinutes(3));
+ // EAST → MIDPOINT_NORTH
+ var segmentCD = createGraphPath(Duration.ofMinutes(2));
+ // MIDPOINT_NORTH → NORTH
+ var segmentDB = createGraphPath(Duration.ofMinutes(4));
+
+ // Setup routing: return all segment mocks for any routing call
+ // The algorithm will evaluate multiple insertion positions
+ @SuppressWarnings("unchecked")
+ final GraphPath[] paths = new GraphPath[] {
+ baselinePath,
+ segmentAC,
+ segmentCD,
+ segmentDB,
+ segmentAC,
+ segmentCD,
+ segmentDB,
+ segmentAC,
+ segmentCD,
+ };
+ final int[] callCount = { 0 };
+ RoutingFunction routingFunction = (from, to, linkingContext) -> {
+ 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, routingFunction);
+
+ assertNotNull(result, "Should find valid insertion");
+
+ // Verify the result structure
+ assertEquals(3, result.routeSegments().size(), "Should have 3 segments in modified route");
+
+ // 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 (approximately, with rounding)
+ // If bug exists, segment A→C would incorrectly use baseline (10 min) → total would be wrong
+ 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
+ assertTrue(
+ result.additionalDuration().isNegative(),
+ "Additional duration should be negative (insertion is faster), got " +
+ result.additionalDuration()
+ );
+
+ // 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
+ 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 = createGraphPath(Duration.ofMinutes(5));
+
+ final int[] callCount = { 0 };
+ RoutingFunction routingFunction = (from, to, linkingContext) -> {
+ callCount[0]++;
+ return mockPath;
+ };
+
+ // Insert passenger - the algorithm will find the best position
+ var result = findOptimalInsertion(trip, OSLO_WEST, OSLO_SOUTH, routingFunction);
+
+ 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"
+ );
+
+ // Routing was called for baseline and new segments
+ assertTrue(callCount[0] >= 2, "Should have called routing at least 2 times");
+ }
+
+ @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_NORTHEAST);
+
+ var mockPath = createGraphPath(Duration.ofMinutes(5));
+
+ final int[] callCount = { 0 };
+ RoutingFunction routingFunction = (from, to, linkingContext) -> {
+ 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, routingFunction);
+
+ 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
+ assertTrue(callCount[0] >= 2, "Should have called routing at least 2 times");
+ }
+
+ @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 = createGraphPath(Duration.ofMinutes(5));
+
+ final int[] callCount = { 0 };
+ RoutingFunction routingFunction = (from, to, linkingContext) -> {
+ callCount[0]++;
+ return mockPath;
+ };
+
+ var result = findOptimalInsertion(trip, OSLO_EAST, OSLO_WEST, routingFunction);
+
+ assertNotNull(result);
+ assertEquals(3, result.routeSegments().size());
+
+ // 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);
+ 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..1f30d33faf2
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/routing/InsertionPositionFinderTest.java
@@ -0,0 +1,109 @@
+package org.opentripplanner.ext.carpooling.routing;
+
+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.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;
+import static org.opentripplanner.ext.carpooling.TestCarpoolTripBuilder.createTripWithStops;
+
+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 stops = List.of(createOriginStop(OSLO_CENTER), createDestinationStop(OSLO_NORTH, 1));
+ var trip = createTripWithCapacity(0, stops);
+
+ 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
+ // 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)
+ // 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/util/BeelineEstimatorTest.java b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java
new file mode 100644
index 00000000000..33f5d8d86ec
--- /dev/null
+++ b/application/src/test/java/org/opentripplanner/ext/carpooling/util/BeelineEstimatorTest.java
@@ -0,0 +1,288 @@
+package org.opentripplanner.ext.carpooling.util;
+
+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;
+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
+ // > 4 minutes
+ assertTrue(duration.getSeconds() > 240);
+ // < 8 minutes
+ assertTrue(duration.getSeconds() < 480);
+ }
+
+ @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
+ // > 5 minutes
+ assertTrue(duration.getSeconds() > 300);
+ // < 10 minutes
+ assertTrue(duration.getSeconds() < 600);
+ }
+
+ @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);
+ // 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)
+ // > 10 minutes
+ assertTrue(times[2].getSeconds() > 600);
+ // < 30 minutes
+ assertTrue(times[2].getSeconds() < 1800);
+ }
+
+ @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.getSpeed());
+
+ var customEstimator = new BeelineEstimator(1.3, 15.0);
+ assertEquals(15.0, customEstimator.getSpeed());
+ }
+
+ @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
+ // > 8.3 hours
+ assertTrue(duration.getSeconds() > 30000);
+ // < 16.7 hours
+ assertTrue(duration.getSeconds() < 60000);
+ }
+
+ @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);
+ }
+}
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);
+ }
}
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);
+ }
}
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 cca4501d9bf..b89cf219401 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,6 +13,7 @@
import java.util.Map;
import java.util.function.Predicate;
import org.opentripplanner.TestServerContext;
+import org.opentripplanner.ext.carpooling.internal.DefaultCarpoolingRepository;
import org.opentripplanner.ext.fares.impl.gtfs.DefaultFareService;
import org.opentripplanner.framework.application.OtpAppException;
import org.opentripplanner.model.plan.Itinerary;
@@ -101,6 +102,7 @@ public SpeedTest(
new DefaultVehicleRentalService(),
new DefaultVehicleParkingRepository(),
timetableRepository,
+ new DefaultCarpoolingRepository(),
new TimetableSnapshotManager(null, TimetableSnapshotParameters.DEFAULT, LocalDate::now),
config.updatersConfig
);
@@ -145,6 +147,7 @@ public SpeedTest(
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 3bf80d7def3..dbc3eab8aa5 100644
--- a/doc/user/Configuration.md
+++ b/doc/user/Configuration.md
@@ -242,6 +242,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. | | ✓️ |
diff --git a/doc/user/sandbox/Carpooling.md b/doc/user/sandbox/Carpooling.md
new file mode 100644
index 00000000000..1098f98d5f0
--- /dev/null
+++ b/doc/user/sandbox/Carpooling.md
@@ -0,0 +1,122 @@
+# 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
+
+The carpooling service matches passengers with compatible carpool trips based on several criteria:
+
+- **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
+
+The system automatically calculates the optimal pickup and dropoff points along the driver's route that minimize additional travel time while respecting all constraints.
+
+### Constraints and Protections
+
+To ensure a good experience for all users, the system enforces several constraints:
+
+- **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)
+
+### Multi-Stop Trips
+
+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
+
+### API Integration
+
+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
+
+- **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 05d383b2f87..0f7f0aa41dd 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'