|
| 1 | +package org.opentripplanner.ext.carpooling.routing; |
| 2 | + |
| 3 | +import static org.junit.jupiter.api.Assertions.assertEquals; |
| 4 | +import static org.junit.jupiter.api.Assertions.assertThrows; |
| 5 | +import static org.opentripplanner.ext.carpooling.CarpoolGraphPathBuilder.createGraphPath; |
| 6 | +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_CENTER; |
| 7 | +import static org.opentripplanner.ext.carpooling.CarpoolTestCoordinates.OSLO_NORTH; |
| 8 | +import static org.opentripplanner.ext.carpooling.CarpoolTripTestData.createSimpleTrip; |
| 9 | + |
| 10 | +import java.time.Duration; |
| 11 | +import java.util.List; |
| 12 | +import javax.annotation.Nullable; |
| 13 | +import org.junit.jupiter.api.Test; |
| 14 | +import org.opentripplanner.astar.model.GraphPath; |
| 15 | +import org.opentripplanner.core.model.basic.Cost; |
| 16 | +import org.opentripplanner.framework.model.TimeAndCost; |
| 17 | +import org.opentripplanner.raptor.spi.RaptorConstants; |
| 18 | +import org.opentripplanner.raptor.spi.RaptorCostConverter; |
| 19 | +import org.opentripplanner.street.model.edge.Edge; |
| 20 | +import org.opentripplanner.street.model.vertex.Vertex; |
| 21 | +import org.opentripplanner.street.search.state.State; |
| 22 | + |
| 23 | +class CarpoolAccessEgressTest { |
| 24 | + |
| 25 | + private static final int STOP = 0; |
| 26 | + private static final Duration STOP_DURATION = Duration.ofMinutes(2); |
| 27 | + private static final int DWELL_SECONDS = (int) STOP_DURATION.getSeconds(); |
| 28 | + private static final int PICKUP_POSITION = 1; |
| 29 | + private static final int DROPOFF_POSITION = 2; |
| 30 | + private static final Duration PICKUP_SEGMENT_DURATION = Duration.ofMinutes(1); |
| 31 | + |
| 32 | + /** |
| 33 | + * Walk time must be billed at the walk path's own A* weight (which already encodes walk |
| 34 | + * reluctance, safety, slope, ...) and the carpool ride at {@code carpoolReluctance}. The ride |
| 35 | + * includes the boarding dwell at the pickup stop. |
| 36 | + */ |
| 37 | + @Test |
| 38 | + void c1AddsWalkPathWeightsAndChargesRideAtCarpoolReluctance() { |
| 39 | + var walkToPickup = createGraphPath(Duration.ofSeconds(80)); |
| 40 | + var walkFromDropoff = createGraphPath(Duration.ofSeconds(40)); |
| 41 | + double carpoolReluctance = 1.0; |
| 42 | + |
| 43 | + var accessEgress = newAccessEgress( |
| 44 | + 1_000, |
| 45 | + walkToPickup, |
| 46 | + Duration.ofSeconds(60), |
| 47 | + walkFromDropoff, |
| 48 | + carpoolReluctance |
| 49 | + ); |
| 50 | + |
| 51 | + int rideSeconds = 60 + DWELL_SECONDS; |
| 52 | + double expectedWeight = |
| 53 | + walkToPickup.getWeight() + walkFromDropoff.getWeight() + rideSeconds * carpoolReluctance; |
| 54 | + assertEquals(RaptorCostConverter.toRaptorCost(expectedWeight), accessEgress.c1()); |
| 55 | + } |
| 56 | + |
| 57 | + /** With no walk, the cost reduces to {@code (sharedSegmentSeconds + dwell) * carpoolReluctance}. */ |
| 58 | + @Test |
| 59 | + void c1WithoutWalkUsesOnlyCarpoolReluctance() { |
| 60 | + var accessEgress = newAccessEgress(0, null, Duration.ofSeconds(300), null, 1.5); |
| 61 | + |
| 62 | + int rideSeconds = 300 + DWELL_SECONDS; |
| 63 | + assertEquals(RaptorCostConverter.toRaptorCost(rideSeconds * 1.5), accessEgress.c1()); |
| 64 | + } |
| 65 | + |
| 66 | + @Test |
| 67 | + void durationInSecondsCoversWalksAndRide() { |
| 68 | + var accessEgress = newAccessEgress( |
| 69 | + 1_000, |
| 70 | + createGraphPath(Duration.ofSeconds(80)), |
| 71 | + Duration.ofSeconds(60), |
| 72 | + createGraphPath(Duration.ofSeconds(40)), |
| 73 | + 1.0 |
| 74 | + ); |
| 75 | + |
| 76 | + int expectedDuration = 80 + 60 + DWELL_SECONDS + 40; |
| 77 | + assertEquals(expectedDuration, accessEgress.durationInSeconds()); |
| 78 | + assertEquals(1_000, accessEgress.getPassengerDepartureTime()); |
| 79 | + assertEquals(1_000 + expectedDuration, accessEgress.getPassengerArrivalTime()); |
| 80 | + } |
| 81 | + |
| 82 | + /** |
| 83 | + * {@code withPenalty} installs the new penalty and preserves the leg's stop and time anchors. |
| 84 | + * Mirroring {@code DefaultAccessEgress}, the penalty's cost is folded into {@link |
| 85 | + * CarpoolAccessEgress#c1()} (Raptor itself does not propagate the time-penalty into c1) and the |
| 86 | + * penalty's time is exposed via {@link CarpoolAccessEgress#timePenalty()}. The wall-clock |
| 87 | + * {@code durationInSeconds} is unchanged because the time-penalty is virtual time inside Raptor, |
| 88 | + * not part of the leg's actual duration. |
| 89 | + */ |
| 90 | + @Test |
| 91 | + void withPenaltyFoldsCostIntoC1AndExposesTimePenalty() { |
| 92 | + var original = newAccessEgress( |
| 93 | + 1_000, |
| 94 | + createGraphPath(Duration.ofSeconds(80)), |
| 95 | + Duration.ofSeconds(60), |
| 96 | + createGraphPath(Duration.ofSeconds(40)), |
| 97 | + 1.0 |
| 98 | + ); |
| 99 | + var newPenalty = new TimeAndCost(Duration.ofSeconds(30), Cost.costOfSeconds(45)); |
| 100 | + |
| 101 | + var withPenalty = (CarpoolAccessEgress) original.withPenalty(newPenalty); |
| 102 | + |
| 103 | + assertEquals(newPenalty, withPenalty.penalty()); |
| 104 | + assertEquals(original.stop(), withPenalty.stop()); |
| 105 | + assertEquals(original.c1() + newPenalty.cost().toCentiSeconds(), withPenalty.c1()); |
| 106 | + assertEquals((int) newPenalty.time().toSeconds(), withPenalty.timePenalty()); |
| 107 | + assertEquals(original.durationInSeconds(), withPenalty.durationInSeconds()); |
| 108 | + assertEquals(original.getPassengerDepartureTime(), withPenalty.getPassengerDepartureTime()); |
| 109 | + assertEquals(original.getPassengerArrivalTime(), withPenalty.getPassengerArrivalTime()); |
| 110 | + } |
| 111 | + |
| 112 | + /** Without a penalty, {@code timePenalty()} returns the Raptor sentinel {@code TIME_NOT_SET}. */ |
| 113 | + @Test |
| 114 | + void timePenaltyDefaultsToTimeNotSet() { |
| 115 | + var subject = newAccessEgress(0, null, Duration.ofSeconds(300), null, 1.0); |
| 116 | + |
| 117 | + assertEquals(RaptorConstants.TIME_NOT_SET, subject.timePenalty()); |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * {@code withPenalty} is a one-shot decoration. Calling it on a leg that already has a non-zero |
| 122 | + * penalty would otherwise re-fold a cost into {@code c1} and silently discard the previous |
| 123 | + * penalty, so the second application throws — matching {@code DefaultAccessEgress}. |
| 124 | + */ |
| 125 | + @Test |
| 126 | + void canNotAddPenaltyTwice() { |
| 127 | + var subject = newAccessEgress( |
| 128 | + 1_000, |
| 129 | + createGraphPath(Duration.ofSeconds(80)), |
| 130 | + Duration.ofSeconds(60), |
| 131 | + createGraphPath(Duration.ofSeconds(40)), |
| 132 | + 1.0 |
| 133 | + ); |
| 134 | + var penalty = new TimeAndCost(Duration.ofSeconds(30), Cost.costOfSeconds(45)); |
| 135 | + var withPenalty = subject.withPenalty(penalty); |
| 136 | + |
| 137 | + assertThrows(IllegalStateException.class, () -> withPenalty.withPenalty(penalty)); |
| 138 | + } |
| 139 | + |
| 140 | + /** |
| 141 | + * Builds a CarpoolAccessEgress with the passenger picked up mid-trip (pickupPos = 1, dropoffPos |
| 142 | + * = 2). The route segments list is therefore [pickupSegment, sharedSegment]: the driver runs |
| 143 | + * the first to reach the passenger and the second is the passenger's shared ride. The |
| 144 | + * passenger's ride duration is {@code sharedSegmentDuration + STOP_DURATION} — the boarding |
| 145 | + * dwell at the pickup stop is part of the ride. |
| 146 | + */ |
| 147 | + private static CarpoolAccessEgress newAccessEgress( |
| 148 | + int passengerDepartureTime, |
| 149 | + @Nullable GraphPath<State, Edge, Vertex> walkToPickup, |
| 150 | + Duration sharedSegmentDuration, |
| 151 | + @Nullable GraphPath<State, Edge, Vertex> walkFromDropoff, |
| 152 | + double carpoolReluctance |
| 153 | + ) { |
| 154 | + var candidate = new InsertionCandidate( |
| 155 | + createSimpleTrip(OSLO_CENTER, OSLO_NORTH), |
| 156 | + PICKUP_POSITION, |
| 157 | + DROPOFF_POSITION, |
| 158 | + List.of(createGraphPath(PICKUP_SEGMENT_DURATION), createGraphPath(sharedSegmentDuration)), |
| 159 | + STOP_DURATION, |
| 160 | + null, |
| 161 | + walkToPickup, |
| 162 | + walkFromDropoff |
| 163 | + ); |
| 164 | + return new CarpoolAccessEgress( |
| 165 | + STOP, |
| 166 | + passengerDepartureTime, |
| 167 | + candidate, |
| 168 | + TimeAndCost.ZERO, |
| 169 | + carpoolReluctance |
| 170 | + ); |
| 171 | + } |
| 172 | +} |
0 commit comments