Skip to content

Commit 22faa4d

Browse files
committed
Size carpool routing trees from cached OTP leg durations with baseline fallback
1 parent 49c7b34 commit 22faa4d

7 files changed

Lines changed: 397 additions & 69 deletions

File tree

application/src/ext-test/java/org/opentripplanner/ext/carpooling/internal/DefaultCarpoolingRepositoryTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77
import java.time.Duration;
88
import java.time.ZoneId;
99
import java.time.ZonedDateTime;
10+
import java.util.List;
1011
import org.junit.jupiter.api.Test;
12+
import org.opentripplanner.core.model.id.FeedScopedId;
1113
import org.opentripplanner.ext.carpooling.CarpoolTripTestData;
14+
import org.opentripplanner.ext.carpooling.model.CarpoolStop;
1215
import org.opentripplanner.ext.carpooling.model.CarpoolTrip;
16+
import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder;
17+
import org.opentripplanner.street.geometry.WgsCoordinate;
1318

1419
class DefaultCarpoolingRepositoryTest {
1520

@@ -92,6 +97,106 @@ void throttlesSweepsToOncePerInterval() {
9297
assertThat(repository.getCarpoolTrips()).isEmpty();
9398
}
9499

100+
@Test
101+
void keepsCachedLegDurationsAcrossASameGeometryUpsert() {
102+
var repository = new DefaultCarpoolingRepository();
103+
var trip = tripWithCoordinates("kept", OSLO_CENTER, OSLO_EAST, NOON.plusHours(1));
104+
repository.upsertCarpoolTrip(trip);
105+
repository.cacheBaselineLegDurations(trip.getId(), new Duration[] { Duration.ofMinutes(12) });
106+
107+
// A budget- or time-only update keeps the same route points, so the cache survives.
108+
var updated = tripWithCoordinates("kept", OSLO_CENTER, OSLO_EAST, NOON.plusHours(2));
109+
repository.upsertCarpoolTrip(updated);
110+
111+
assertThat(repository.cachedBaselineLegDurations(trip.getId()))
112+
.asList()
113+
.containsExactly(Duration.ofMinutes(12));
114+
}
115+
116+
@Test
117+
void dropsCachedLegDurationsWhenRoutePointsChange() {
118+
var repository = new DefaultCarpoolingRepository();
119+
var trip = tripWithCoordinates("moved", OSLO_CENTER, OSLO_EAST, NOON.plusHours(1));
120+
repository.upsertCarpoolTrip(trip);
121+
repository.cacheBaselineLegDurations(trip.getId(), new Duration[] { Duration.ofMinutes(12) });
122+
123+
var rerouted = tripWithCoordinates(
124+
"moved",
125+
OSLO_CENTER,
126+
new WgsCoordinate(60.0, 11.0),
127+
NOON.plusHours(1)
128+
);
129+
repository.upsertCarpoolTrip(rerouted);
130+
131+
assertThat(repository.cachedBaselineLegDurations(trip.getId())).isNull();
132+
}
133+
134+
@Test
135+
void dropsCachedLegDurationsWhenTripRemoved() {
136+
var repository = new DefaultCarpoolingRepository();
137+
var trip = tripWithCoordinates("removed", OSLO_CENTER, OSLO_EAST, NOON.plusHours(1));
138+
repository.upsertCarpoolTrip(trip);
139+
repository.cacheBaselineLegDurations(trip.getId(), new Duration[] { Duration.ofMinutes(12) });
140+
141+
repository.removeCarpoolTrip(trip.getId());
142+
143+
assertThat(repository.cachedBaselineLegDurations(trip.getId())).isNull();
144+
}
145+
146+
@Test
147+
void dropsCachedLegDurationsWhenTripExpires() {
148+
var repository = new DefaultCarpoolingRepository();
149+
var trip = tripWithCoordinates("expired", OSLO_CENTER, OSLO_EAST, NOON);
150+
repository.upsertCarpoolTrip(trip);
151+
repository.cacheBaselineLegDurations(trip.getId(), new Duration[] { Duration.ofMinutes(12) });
152+
153+
repository.removeExpiredTrips(NOON.plusHours(1).toInstant(), Duration.ZERO);
154+
155+
assertThat(repository.cachedBaselineLegDurations(trip.getId())).isNull();
156+
}
157+
158+
@Test
159+
void returnsADefensiveCopyOfCachedLegDurations() {
160+
var repository = new DefaultCarpoolingRepository();
161+
var trip = tripWithCoordinates("copy", OSLO_CENTER, OSLO_EAST, NOON.plusHours(1));
162+
repository.upsertCarpoolTrip(trip);
163+
var stored = new Duration[] { Duration.ofMinutes(12) };
164+
repository.cacheBaselineLegDurations(trip.getId(), stored);
165+
166+
// Neither mutating the stored source array nor the returned array may corrupt the cache.
167+
stored[0] = Duration.ofMinutes(99);
168+
repository.cachedBaselineLegDurations(trip.getId())[0] = Duration.ofMinutes(7);
169+
170+
assertThat(repository.cachedBaselineLegDurations(trip.getId()))
171+
.asList()
172+
.containsExactly(Duration.ofMinutes(12));
173+
}
174+
175+
private static CarpoolTrip tripWithCoordinates(
176+
String id,
177+
WgsCoordinate origin,
178+
WgsCoordinate destination,
179+
ZonedDateTime endTime
180+
) {
181+
return new CarpoolTripBuilder(FeedScopedId.ofNullable("TEST", id))
182+
.withStops(
183+
List.of(
184+
CarpoolStop.of(FeedScopedId.ofNullable("TEST", id + "-origin"))
185+
.withCoordinate(origin)
186+
.withOnboardCount(1)
187+
.build(),
188+
CarpoolStop.of(FeedScopedId.ofNullable("TEST", id + "-destination"))
189+
.withCoordinate(destination)
190+
.withOnboardCount(1)
191+
.build()
192+
)
193+
)
194+
.withTotalCapacity(CarpoolTrip.DEFAULT_TOTAL_CAPACITY)
195+
.withStartTime(NOON)
196+
.withEndTime(endTime)
197+
.build();
198+
}
199+
95200
private static CarpoolTrip tripEndingAt(ZonedDateTime endTime) {
96201
return CarpoolTripTestData.createSimpleTripWithTimes(
97202
OSLO_CENTER,

application/src/ext-test/java/org/opentripplanner/ext/carpooling/service/DefaultCarpoolingServiceTreeLimitTest.java

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package org.opentripplanner.ext.carpooling.service;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
45
import static org.junit.jupiter.api.Assertions.assertTrue;
5-
import static org.opentripplanner.ext.carpooling.service.DefaultCarpoolingService.LEG_DURATION_SLACK;
66
import static org.opentripplanner.ext.carpooling.service.DefaultCarpoolingService.MAX_SEARCH_DURATION_FOR_NEARBY_STOPS_FOR_ACCESS_EGRESS;
77

88
import java.time.Duration;
@@ -16,13 +16,19 @@
1616
import org.opentripplanner.ext.carpooling.model.CarpoolStop;
1717
import org.opentripplanner.ext.carpooling.model.CarpoolTrip;
1818
import org.opentripplanner.ext.carpooling.model.CarpoolTripBuilder;
19+
import org.opentripplanner.ext.carpooling.updater.CarpoolSiriMapper;
1920
import org.opentripplanner.street.geometry.WgsCoordinate;
2021

2122
/**
22-
* Unit tests for {@link DefaultCarpoolingService#driverLegTreeLimits}: each leg gets
23-
* {@code leg + slack + smallestDownstreamBudget}, multi-leg trips get per-leg limits well below
24-
* the whole-trip limit, and an incomplete or non-monotonic timeline falls back to whole-trip
25-
* sizing for every leg.
23+
* Unit tests for {@link DefaultCarpoolingService#driverLegTreeLimits} and
24+
* {@link DefaultCarpoolingService#scheduledLegDurations}: each leg gets
25+
* {@code leg + slack + smallestDownstreamBudget} capped at
26+
* {@link org.opentripplanner.ext.carpooling.updater.CarpoolSiriMapper#MAX_TRIP_DURATION}, multi-leg
27+
* trips get per-leg limits well below the whole-trip limit, and an incomplete or non-monotonic
28+
* schedule yields no scheduled durations (signalling the caller to fall back to whole-trip sizing).
29+
* The leg durations here come from the SIRI schedule; in production they are OTP's own routed
30+
* durations (see {@link DefaultCarpoolingService#resolveLegDurations}), but the limit arithmetic is
31+
* identical.
2632
*/
2733
class DefaultCarpoolingServiceTreeLimitTest {
2834

@@ -33,11 +39,26 @@ class DefaultCarpoolingServiceTreeLimitTest {
3339

3440
private static int idCounter = 0;
3541

42+
/** Mirrors the small slack {@link DefaultCarpoolingService#driverLegTreeLimits} adds per leg. */
43+
private static final Duration SLACK = Duration.ofMinutes(1);
44+
3645
/**
3746
* Matches the production formula: leg + flat slack + smallest downstream deviation budget.
3847
*/
3948
private static Duration expectedLimit(Duration leg, Duration allowance) {
40-
return leg.plus(LEG_DURATION_SLACK).plus(allowance);
49+
return leg.plus(SLACK).plus(allowance);
50+
}
51+
52+
/**
53+
* Sizes the trip's legs from its SIRI schedule, exercising the same
54+
* {@link DefaultCarpoolingService#driverLegTreeLimits} arithmetic production uses with OTP's
55+
* routed durations.
56+
*/
57+
private static Duration[] limitsFromSchedule(CarpoolTrip trip) {
58+
return DefaultCarpoolingService.driverLegTreeLimits(
59+
trip,
60+
DefaultCarpoolingService.scheduledLegDurations(trip)
61+
);
4162
}
4263

4364
@Test
@@ -50,7 +71,7 @@ void sizesEachLegIndependentlyAndWellBelowWholeTrip() {
5071
destinationStop(BASE.plusMinutes(160), BUDGET)
5172
);
5273

53-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
74+
var limits = limitsFromSchedule(trip);
5475

5576
var leg0 = expectedLimit(Duration.ofMinutes(60), BUDGET);
5677
var leg1 = expectedLimit(Duration.ofMinutes(100), BUDGET);
@@ -77,12 +98,12 @@ void usesSmallestDownstreamBudgetAsDetourAllowance() {
7798
destinationStop(BASE.plusMinutes(120), Duration.ofMinutes(40))
7899
);
79100

80-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
101+
var limits = limitsFromSchedule(trip);
81102

82-
// Leg 0: allowance min(5, 40) = 5 → 60 + 15 + 5 = 80; the origin's budget never participates.
83-
assertEquals(Duration.ofMinutes(80), limits[0]);
84-
// Leg 1 only delays the destination: allowance 40 → 60 + 15 + 40 = 115.
85-
assertEquals(Duration.ofMinutes(115), limits[1]);
103+
// Leg 0: allowance min(5, 40) = 5; the origin's budget never participates.
104+
assertEquals(expectedLimit(Duration.ofMinutes(60), Duration.ofMinutes(5)), limits[0]);
105+
// Leg 1 only delays the destination: allowance 40.
106+
assertEquals(expectedLimit(Duration.ofMinutes(60), Duration.ofMinutes(40)), limits[1]);
86107
}
87108

88109
@Test
@@ -96,7 +117,7 @@ void sizesShortLegsWellBelowNearbyStopRadius() {
96117
destinationStop(BASE.plusMinutes(10), Duration.ofMinutes(2))
97118
);
98119

99-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
120+
var limits = limitsFromSchedule(trip);
100121

101122
var leg = expectedLimit(Duration.ofMinutes(5), Duration.ofMinutes(2));
102123
assertEquals(leg, limits[0]);
@@ -116,7 +137,7 @@ void usesAimedArrivalWhenExpectedMissing() {
116137
destinationStop(BASE.plusMinutes(90), BUDGET)
117138
);
118139

119-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
140+
var limits = limitsFromSchedule(trip);
120141

121142
assertEquals(expectedLimit(Duration.ofMinutes(50), BUDGET), limits[0]);
122143
assertEquals(expectedLimit(Duration.ofMinutes(40), BUDGET), limits[1]);
@@ -133,7 +154,7 @@ void destinationLatestArrivalDoesNotInflateLastLeg() {
133154
destinationStopWithTimes(BASE.plusMinutes(120), BASE.plusMinutes(160), BUDGET)
134155
);
135156

136-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
157+
var limits = limitsFromSchedule(trip);
137158

138159
// Last leg is the scheduled 60 min (12:00+60 → +120), not the 100 min the latest expected
139160
// arrival (+160) would give.
@@ -148,62 +169,90 @@ void twoWaypointTripSizesTheSingleLegToTheWholeSpan() {
148169
destinationStop(BASE.plusMinutes(60), BUDGET)
149170
);
150171

151-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
172+
var limits = limitsFromSchedule(trip);
152173

153174
var legLimit = expectedLimit(Duration.ofMinutes(60), BUDGET);
154175
assertEquals(1, limits.length);
155176
assertEquals(legLimit, limits[0]);
156177
}
157178

158179
@Test
159-
void fallsBackToWholeTripWhenIntermediateTimeMissing() {
180+
void capsEachLegLimitAtMaxTripDuration() {
181+
// An inconsistent trip whose leg travel time already exceeds the trip cap (e.g. one that
182+
// slipped past the mapper) must not size a tree beyond MAX_TRIP_DURATION; otherwise a single
183+
// request could expand a multi-hour street tree.
184+
var trip = trip(
185+
BASE.plusMinutes(200),
186+
originStop(BUDGET),
187+
destinationStop(BASE.plusMinutes(200), BUDGET)
188+
);
189+
190+
var limits = DefaultCarpoolingService.driverLegTreeLimits(
191+
trip,
192+
new Duration[] { Duration.ofHours(4) }
193+
);
194+
195+
assertEquals(CarpoolSiriMapper.MAX_TRIP_DURATION, limits[0]);
196+
}
197+
198+
@Test
199+
void doesNotCapLegLimitsThatStayWithinMaxTripDuration() {
200+
// A leg comfortably under the cap keeps its full duration + slack + allowance — the cap only
201+
// bites the extreme case above.
202+
var trip = trip(
203+
BASE.plusMinutes(60),
204+
originStop(BUDGET),
205+
destinationStop(BASE.plusMinutes(60), BUDGET)
206+
);
207+
208+
var limits = DefaultCarpoolingService.driverLegTreeLimits(
209+
trip,
210+
new Duration[] { Duration.ofMinutes(30) }
211+
);
212+
213+
assertEquals(expectedLimit(Duration.ofMinutes(30), BUDGET), limits[0]);
214+
assertTrue(limits[0].compareTo(CarpoolSiriMapper.MAX_TRIP_DURATION) < 0);
215+
}
216+
217+
@Test
218+
void scheduledLegDurationsIsNullWhenIntermediateTimeMissing() {
160219
var trip = trip(
161220
BASE.plusMinutes(120),
162221
originStop(BUDGET),
163222
intermediateStop(null, BUDGET),
164223
destinationStop(BASE.plusMinutes(120), BUDGET)
165224
);
166225

167-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
168-
169-
var wholeTrip = expectedLimit(Duration.ofMinutes(120), BUDGET);
170-
assertEquals(wholeTrip, limits[0]);
171-
assertEquals(wholeTrip, limits[1]);
226+
// No scheduled durations → the caller sizes every leg from the whole-trip span instead.
227+
assertNull(DefaultCarpoolingService.scheduledLegDurations(trip));
172228
}
173229

174230
@Test
175-
void fallsBackToWholeTripWhenDestinationTimeMissing() {
176-
// The destination is not special-cased: its missing arrival falls back like any other stop's.
231+
void scheduledLegDurationsIsNullWhenDestinationTimeMissing() {
232+
// The destination is not special-cased: its missing arrival yields no scheduled durations,
233+
// like any other stop's.
177234
var trip = trip(
178235
BASE.plusMinutes(120),
179236
originStop(BUDGET),
180237
intermediateStop(BASE.plusMinutes(60), BUDGET),
181238
destinationStop(null, BUDGET)
182239
);
183240

184-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
185-
186-
var wholeTrip = expectedLimit(Duration.ofMinutes(120), BUDGET);
187-
assertEquals(wholeTrip, limits[0]);
188-
assertEquals(wholeTrip, limits[1]);
241+
assertNull(DefaultCarpoolingService.scheduledLegDurations(trip));
189242
}
190243

191244
@Test
192-
void fallsBackToWholeTripWhenTimelineNotMonotonic() {
245+
void scheduledLegDurationsIsNullWhenTimelineNotMonotonic() {
193246
// Intermediate arrival is before the trip start — an impossible timeline that must not yield a
194-
// negative leg; the whole-trip span sizes every leg instead.
247+
// negative leg; the caller sizes every leg from the whole-trip span instead.
195248
var trip = trip(
196249
BASE.plusMinutes(120),
197250
originStop(BUDGET),
198251
intermediateStop(BASE.minusMinutes(10), BUDGET),
199252
destinationStop(BASE.plusMinutes(120), BUDGET)
200253
);
201254

202-
var limits = DefaultCarpoolingService.driverLegTreeLimits(trip);
203-
204-
var wholeTrip = expectedLimit(Duration.ofMinutes(120), BUDGET);
205-
assertEquals(wholeTrip, limits[0]);
206-
assertEquals(wholeTrip, limits[1]);
255+
assertNull(DefaultCarpoolingService.scheduledLegDurations(trip));
207256
}
208257

209258
private static CarpoolTrip trip(ZonedDateTime endTime, CarpoolStop... stops) {

application/src/ext/java/org/opentripplanner/ext/carpooling/CarpoolingRepository.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.time.Duration;
44
import java.time.Instant;
55
import java.util.Collection;
6+
import javax.annotation.Nullable;
67
import org.opentripplanner.core.model.id.FeedScopedId;
78
import org.opentripplanner.ext.carpooling.model.CarpoolTrip;
89

@@ -71,4 +72,26 @@ public interface CarpoolingRepository {
7172
* @return the number of trips removed, or {@code 0} when the call was throttled
7273
*/
7374
int removeExpiredTrips(Instant now, Duration expiry);
75+
76+
/**
77+
* Returns OTP's routed travel duration for each leg of the trip with the given id, or
78+
* {@code null} if none are cached.
79+
* <p>
80+
* These durations are a routing memoization, not part of the trip's domain data: they depend
81+
* only on the trip's waypoint geometry and the static street graph, never on the passenger
82+
* request, so they are computed once and reused across requests. The repository keeps them
83+
* because it owns the trip lifecycle that decides when they go stale — they are dropped when the
84+
* trip's route points change on {@link #upsertCarpoolTrip} and when the trip is removed or
85+
* expires. The returned array has one entry per leg, i.e. {@code stops().size() - 1} entries,
86+
* and is a copy the caller may not mutate into the cache.
87+
*/
88+
@Nullable
89+
Duration[] cachedBaselineLegDurations(FeedScopedId tripId);
90+
91+
/**
92+
* Stores OTP's routed travel duration for each leg of the trip with the given id, replacing any
93+
* previously cached value (see {@link #cachedBaselineLegDurations}). The array is copied, so
94+
* later mutation by the caller does not affect the cache.
95+
*/
96+
void cacheBaselineLegDurations(FeedScopedId tripId, Duration[] legDurations);
7497
}

0 commit comments

Comments
 (0)