diff --git a/model/definition/src/main/java/ai/timefold/solver/model/definition/api/configuration/MapsConfiguration.java b/model/definition/src/main/java/ai/timefold/solver/model/definition/api/configuration/MapsConfiguration.java index e13618dd50..94ec558a25 100644 --- a/model/definition/src/main/java/ai/timefold/solver/model/definition/api/configuration/MapsConfiguration.java +++ b/model/definition/src/main/java/ai/timefold/solver/model/definition/api/configuration/MapsConfiguration.java @@ -4,13 +4,35 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; @JsonInclude(Include.NON_NULL) -public record MapsConfiguration(String provider, String location, Double maxDistanceFromRoad, String transportType) { +public record MapsConfiguration(String provider, String location, Double maxDistanceFromRoad, String transportType, + Boolean useTraffic) { + + public MapsConfiguration(String provider) { + this(provider, null, null, null, null); + } + + public MapsConfiguration withLocation(String location) { + return new MapsConfiguration(provider, location, maxDistanceFromRoad, transportType, useTraffic); + } + + public MapsConfiguration withMaxDistanceFromRoad(Double maxDistanceFromRoad) { + return new MapsConfiguration(provider, location, maxDistanceFromRoad, transportType, useTraffic); + } + + public MapsConfiguration withTransportType(String transportType) { + return new MapsConfiguration(provider, location, maxDistanceFromRoad, transportType, useTraffic); + } + + public MapsConfiguration withUseTraffic(Boolean useTraffic) { + return new MapsConfiguration(provider, location, maxDistanceFromRoad, transportType, useTraffic); + } public MapsConfiguration override(MapsConfiguration configuration) { String finalLocation = location; String finalProvider = provider; Double finalMaxDistanceFromRoad = maxDistanceFromRoad; String finalTransportType = transportType; + Boolean finalUseTraffic = useTraffic; if (configuration == null) { return this; @@ -28,8 +50,12 @@ public MapsConfiguration override(MapsConfiguration configuration) { if (transportType == null) { finalTransportType = configuration.transportType(); } + if (useTraffic == null) { + finalUseTraffic = configuration.useTraffic(); + } - return new MapsConfiguration(finalProvider, finalLocation, finalMaxDistanceFromRoad, finalTransportType); + return new MapsConfiguration(finalProvider, finalLocation, finalMaxDistanceFromRoad, finalTransportType, + finalUseTraffic); } } diff --git a/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/Location.java b/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/Location.java index 1a99ffc2f7..7b4f4bf76c 100644 --- a/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/Location.java +++ b/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/Location.java @@ -1,5 +1,8 @@ package ai.timefold.solver.model.maps.api.model; +import java.time.OffsetDateTime; +import java.util.function.ToIntFunction; + import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -38,6 +41,15 @@ public class Location { @JsonIgnore private short distanceMatrixIndex = IndexableDistanceMatrix.EMPTY_INDEX; + @JsonIgnore + private DistanceMatrix[] travelTimesByTimeframe; + + @JsonIgnore + private DistanceMatrix[] distancesByTimeframe; + + @JsonIgnore + private ToIntFunction timeframeIndexResolver; + public Location() { } @@ -50,6 +62,10 @@ public Location(@JsonProperty("latitude") @Min(-90) @Max(90) double latitude, validateLongitude(); } + public static Location of(double latitude, double longitude) { + return new Location(latitude, longitude); + } + public double getLatitude() { return latitude; } @@ -80,6 +96,18 @@ public void setDistanceMatrix(DistanceMatrix distanceMatrix) { updateIndex(this.distanceMatrix); } + public void setTravelTimeMatrices(DistanceMatrix[] travelTimesByTimeframe, + ToIntFunction indexResolver) { + this.travelTimesByTimeframe = travelTimesByTimeframe; + this.timeframeIndexResolver = indexResolver; + } + + public void setDistanceMatrices(DistanceMatrix[] distancesByTimeframe, + ToIntFunction indexResolver) { + this.distancesByTimeframe = distancesByTimeframe; + this.timeframeIndexResolver = indexResolver; + } + /** * Returns the travel time for a route between this location and the given location. * @@ -102,6 +130,28 @@ public TravelTime getTravelTimeTo(Location location) { return TravelTime.of(travelTimeFromMatrix); } + /** + * Returns the travel time for a route between this location and the given location at the given departure time. + * + * @param location the location representing the route destination + * @param departureTime the instant used to select the traffic timeframe matrix + * @return {@link TravelTime} instance representing the travel time in seconds and indicating if the destination is + * unreachable from this location. + * @throws IllegalArgumentException When the resolved matrix does not include both locations, or the resolver returns + * an out-of-bounds index. + * @throws IllegalStateException When there is no timeframe travel time matrix configured for this location. + */ + public TravelTime getTravelTimeTo(Location location, OffsetDateTime departureTime) { + DistanceMatrix matrix = resolveTimeframeMatrix(travelTimesByTimeframe, departureTime, "travel time"); + long travelTime = matrix.get(this, location); + if (travelTime == -1) { + throw new IllegalArgumentException( + ("No travel time information found for a route from (%s) to (%s) at (%s).") + .formatted(this, location, departureTime)); + } + return TravelTime.of(travelTime); + } + @Deprecated public TravelTime getDrivingTimeTo(Location location) { return getTravelTimeTo(location); @@ -129,6 +179,27 @@ public TravelDistance getDistanceTo(Location location) { return TravelDistance.of(travelDistanceFromMatrix); } + /** + * Returns the travel distance for a route between this location and the given location at the given departure time. + * + * @param location the location representing the route destination + * @param departureTime the instant used to select the traffic timeframe matrix + * @return {@link TravelDistance} instance representing the travel distance in meters and indicating if the destination is + * unreachable from this location. + * @throws IllegalArgumentException When the resolved matrix does not include both locations, or the resolver returns + * an out-of-bounds index. + * @throws IllegalStateException When there is no timeframe distance matrix configured for this location. + */ + public TravelDistance getDistanceTo(Location location, OffsetDateTime departureTime) { + DistanceMatrix matrix = resolveTimeframeMatrix(distancesByTimeframe, departureTime, "distance"); + long distance = matrix.get(this, location); + if (distance == -1) { + throw new IllegalArgumentException(("No distance information found for a route from (%s) to (%s) at (%s).") + .formatted(this, location, departureTime)); + } + return TravelDistance.of(distance); + } + public short getIndex(DistanceMatrix matrix) { if (matrix == travelTimeMatrix) { return travelTimeMatrixIndex; @@ -147,6 +218,26 @@ public void setIndex(DistanceMatrix matrix, short index) { } } + private DistanceMatrix resolveTimeframeMatrix(DistanceMatrix[] matrices, OffsetDateTime departureTime, String what) { + if (matrices == null || timeframeIndexResolver == null) { + throw new IllegalStateException( + ("No traffic-aware %s matrices configured for a location (%s).").formatted(what, this)); + } + int index = timeframeIndexResolver.applyAsInt(departureTime); + if (index < 0 || index >= matrices.length) { + throw new IllegalArgumentException( + ("Resolved timeframe index %d is out of bounds for %d %s matrix/matrices on location (%s) at (%s).") + .formatted(index, matrices.length, what, this, departureTime)); + } + DistanceMatrix matrix = matrices[index]; + if (matrix == null) { + throw new IllegalArgumentException( + ("No %s matrix fetched for timeframe index %d on location (%s) at (%s).") + .formatted(what, index, this, departureTime)); + } + return matrix; + } + private void updateIndex(DistanceMatrix distanceMatrix) { if (distanceMatrix instanceof IndexableDistanceMatrix indexableDistanceMatrix) { indexableDistanceMatrix.updateCachedIndex(this); diff --git a/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/TimeInterval.java b/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/TimeInterval.java new file mode 100644 index 0000000000..1c5a9f9f92 --- /dev/null +++ b/model/maps/api/src/main/java/ai/timefold/solver/model/maps/api/model/TimeInterval.java @@ -0,0 +1,22 @@ +package ai.timefold.solver.model.maps.api.model; + +import java.time.OffsetDateTime; +import java.util.Objects; + +/** + * A half-open time interval {@code [from, to)} representing the period during which a location may be involved in + * travel. {@link #from()} is inclusive; {@link #to()} is exclusive. An interval where {@code from.equals(to)} is a + * zero-length (empty) interval — it still carries the meaning that the location is relevant at exactly {@code from}. + */ +public record TimeInterval(OffsetDateTime from, OffsetDateTime to) { + + public TimeInterval { + Objects.requireNonNull(from, "from"); + Objects.requireNonNull(to, "to"); + if (from.isAfter(to)) { + throw new IllegalArgumentException( + "from (%s) must not be after to (%s)".formatted(from, to)); + } + } + +} diff --git a/model/maps/api/src/test/java/ai/timefold/solver/model/maps/api/model/LocationTest.java b/model/maps/api/src/test/java/ai/timefold/solver/model/maps/api/model/LocationTest.java new file mode 100644 index 0000000000..e0cd5b32bc --- /dev/null +++ b/model/maps/api/src/test/java/ai/timefold/solver/model/maps/api/model/LocationTest.java @@ -0,0 +1,147 @@ +package ai.timefold.solver.model.maps.api.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.function.ToIntFunction; + +import ai.timefold.solver.model.maps.api.DistanceMatrix; +import ai.timefold.solver.model.maps.api.model.travel.TravelDistance; +import ai.timefold.solver.model.maps.api.model.travel.TravelTime; + +import org.junit.jupiter.api.Test; + +class LocationTest { + + // Hour-based stand-in bucketing for the tests: 0 = morning, 1 = afternoon, 2 = night. + private static final int MORNING = 0; + private static final int AFTERNOON = 1; + private static final int NIGHT = 2; + + private static final ToIntFunction INDEX_RESOLVER = at -> { + int hour = at.getHour(); + if (hour < 12) + return MORNING; + if (hour < 18) + return AFTERNOON; + return NIGHT; + }; + + private static final OffsetDateTime MORNING_AT = OffsetDateTime.of(2024, 1, 1, 8, 0, 0, 0, ZoneOffset.UTC); + private static final OffsetDateTime AFTERNOON_AT = OffsetDateTime.of(2024, 1, 1, 16, 0, 0, 0, ZoneOffset.UTC); + private static final OffsetDateTime NIGHT_AT = OffsetDateTime.of(2024, 1, 1, 22, 0, 0, 0, ZoneOffset.UTC); + + @Test + void picksCorrectTravelTimeMatrixPerTimeframe() { + Location from = new Location(0, 0); + Location to = new Location(1, 1); + + DistanceMatrix morningTravel = DistanceMatrix.getInstance(2); + morningTravel.put(from, to, 100L); + DistanceMatrix afternoonTravel = DistanceMatrix.getInstance(2); + afternoonTravel.put(from, to, 500L); + + DistanceMatrix[] travelByTimeframe = new DistanceMatrix[3]; + travelByTimeframe[MORNING] = morningTravel; + travelByTimeframe[AFTERNOON] = afternoonTravel; + + from.setTravelTimeMatrices(travelByTimeframe, INDEX_RESOLVER); + + assertThat(from.getTravelTimeTo(to, MORNING_AT)).isEqualTo(TravelTime.of(100L)); + assertThat(from.getTravelTimeTo(to, AFTERNOON_AT)).isEqualTo(TravelTime.of(500L)); + } + + @Test + void picksCorrectDistanceMatrixPerTimeframe() { + Location from = new Location(0, 0); + Location to = new Location(1, 1); + + DistanceMatrix morningDistance = DistanceMatrix.getInstance(2); + morningDistance.put(from, to, 1_000L); + DistanceMatrix afternoonDistance = DistanceMatrix.getInstance(2); + afternoonDistance.put(from, to, 1_200L); + + DistanceMatrix[] distanceByTimeframe = new DistanceMatrix[3]; + distanceByTimeframe[MORNING] = morningDistance; + distanceByTimeframe[AFTERNOON] = afternoonDistance; + + from.setDistanceMatrices(distanceByTimeframe, INDEX_RESOLVER); + + assertThat(from.getDistanceTo(to, MORNING_AT)).isEqualTo(TravelDistance.of(1_000L)); + assertThat(from.getDistanceTo(to, AFTERNOON_AT)).isEqualTo(TravelDistance.of(1_200L)); + } + + @Test + void timeframelessAndTimeframeOverloadsUseTheirOwnMatrices() { + Location from = new Location(0, 0); + Location to = new Location(1, 1); + + DistanceMatrix singleTravel = DistanceMatrix.getInstance(2); + singleTravel.put(from, to, 10L); + DistanceMatrix singleDistance = DistanceMatrix.getInstance(2); + singleDistance.put(from, to, 20L); + DistanceMatrix morningTravel = DistanceMatrix.getInstance(2); + morningTravel.put(from, to, 100L); + DistanceMatrix morningDistance = DistanceMatrix.getInstance(2); + morningDistance.put(from, to, 1_000L); + + DistanceMatrix[] travelByTimeframe = new DistanceMatrix[3]; + travelByTimeframe[MORNING] = morningTravel; + DistanceMatrix[] distanceByTimeframe = new DistanceMatrix[3]; + distanceByTimeframe[MORNING] = morningDistance; + + from.setTravelTimeMatrix(singleTravel); + from.setDistanceMatrix(singleDistance); + from.setTravelTimeMatrices(travelByTimeframe, INDEX_RESOLVER); + from.setDistanceMatrices(distanceByTimeframe, INDEX_RESOLVER); + + assertThat(from.getTravelTimeTo(to)).isEqualTo(TravelTime.of(10L)); + assertThat(from.getDistanceTo(to)).isEqualTo(TravelDistance.of(20L)); + assertThat(from.getTravelTimeTo(to, MORNING_AT)).isEqualTo(TravelTime.of(100L)); + assertThat(from.getDistanceTo(to, MORNING_AT)).isEqualTo(TravelDistance.of(1_000L)); + } + + @Test + void missingTimeframeCellThrows() { + Location from = new Location(0, 0); + Location to = new Location(1, 1); + + DistanceMatrix morningTravel = DistanceMatrix.getInstance(2); + morningTravel.put(from, to, 100L); + DistanceMatrix[] travelByTimeframe = new DistanceMatrix[3]; + travelByTimeframe[MORNING] = morningTravel; + from.setTravelTimeMatrices(travelByTimeframe, INDEX_RESOLVER); + + assertThatIllegalArgumentException() + .isThrownBy(() -> from.getTravelTimeTo(to, NIGHT_AT)) + .withMessageContaining("No travel time matrix fetched"); + } + + @Test + void unconfiguredTimeframeLookupThrows() { + Location from = new Location(0, 0); + Location to = new Location(1, 1); + + assertThatIllegalStateException().isThrownBy(() -> from.getTravelTimeTo(to, MORNING_AT)); + assertThatIllegalStateException().isThrownBy(() -> from.getDistanceTo(to, MORNING_AT)); + } + + @Test + void outOfBoundsIndexThrows() { + Location from = new Location(0, 0); + Location to = new Location(1, 1); + + DistanceMatrix matrix = DistanceMatrix.getInstance(2); + matrix.put(from, to, 100L); + DistanceMatrix[] byTimeframe = new DistanceMatrix[] { matrix }; + from.setTravelTimeMatrices(byTimeframe, at -> 5); + + assertThatIllegalArgumentException() + .isThrownBy(() -> from.getTravelTimeTo(to, MORNING_AT)) + .withMessageContaining("out of bounds"); + } + +} diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/MapService.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/MapService.java index 1d63318c0c..64b839ca4e 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/MapService.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/MapService.java @@ -1,14 +1,34 @@ package ai.timefold.solver.model.maps.service.client.api; import java.util.List; +import java.util.Map; import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; +import ai.timefold.solver.model.maps.service.client.api.model.TravelTimesByAvailabilityWithMetadata; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceWithMetadata; public interface MapService { TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List locations, String options); + /** + * Fetches one travel-time matrix and one distance matrix per timeframe needed by the given availability map. Each + * timeframe request is pruned to the locations that will be queried in that timeframe. The bucketing used is + * returned in the result so downstream code can stamp the same resolver onto the locations and guarantee lookup-time + * resolution matches build-time resolution. + * + * @param timeAvailability for each location, the time intervals during which it may be involved in travel. Each + * {@link TimeInterval} is half-open {@code [from, to)} and is mapped to the set of timeframe buckets it + * overlaps, and the location is included in each of those timeframe matrices. E.g. for a location {@code L} + * with interval {@code [07:00, 13:00)} under morning/afternoon/night bucketing, {@code L} appears in both + * the morning and afternoon matrices but not in the night matrix. Locations not listed (or listed with an + * empty interval list) are excluded from every timeframe; if no intervals are provided across the whole map, + * an {@link IllegalArgumentException} is thrown. + */ + TravelTimesByAvailabilityWithMetadata getTravelTimeAndDistance(List locations, String options, + Map> timeAvailability); + List getWaypoints(List locations, String options); List getLocationsOutOfMap(List locations, String options); diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java index 91eb4e5387..c9664d68e7 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -3,6 +3,7 @@ import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; +import java.util.Map; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -11,8 +12,11 @@ import ai.timefold.solver.model.definition.internal.error.ErrorCodes; import ai.timefold.solver.model.definition.internal.error.TimefoldRuntimeException; import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; +import ai.timefold.solver.model.maps.service.client.api.model.TravelTimesByAvailabilityWithMetadata; import ai.timefold.solver.model.maps.service.client.impl.MapServiceOptionsSupplier; import ai.timefold.solver.model.maps.service.client.impl.error.MapServiceIllegalArgumentException; +import ai.timefold.solver.model.maps.service.integration.api.LocationsAndTrafficAwareSolverModel; import ai.timefold.solver.model.maps.service.integration.api.LocationsAwareSolverModel; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceConverterException; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceWithMetadata; @@ -43,6 +47,21 @@ public TravelTimeMatrixEnricher(MapService mapService, MapServiceOptionsSupplier }) @Override public LocationsAwareSolverModel enrich(LocationsAwareSolverModel solverModel) { + // Two enricher paths driven by the model interface: + // - LocationsAwareSolverModel -> single matrix, scalar setters. Whether the underlying data is + // plain or traffic-aware-at-default-timeframe is decided inside MapServiceClientImpl based on the + // use-traffic flag. + // - LocationsAndTrafficAwareSolverModel -> per-timeframe matrices, array setters. With traffic on, that's + // one matrix per overlapping timeframe; with traffic off, MapServiceClientImpl wraps a single plain matrix + // as a one-bucket array (resolver always returns 0). + // The use-traffic config flag lives in MapServiceClientImpl; the enricher doesn't read it. + if (solverModel instanceof LocationsAndTrafficAwareSolverModel trafficAwareSolverModel) { + return enrichAvailabilityAware(trafficAwareSolverModel); + } + return enrichSingleMatrix(solverModel); + } + + private LocationsAwareSolverModel enrichSingleMatrix(LocationsAwareSolverModel solverModel) { List locations = solverModel.getLocations(); // Get all the locations from the model only once. TravelTimeAndDistanceWithMetadata travelTimeAndDistance; try { @@ -64,6 +83,28 @@ public LocationsAwareSolverModel enrich(LocationsAwareSolverModel solverMo return solverModel; } + private LocationsAwareSolverModel enrichAvailabilityAware(LocationsAndTrafficAwareSolverModel solverModel) { + List locations = solverModel.getLocations(); + Map> availability = solverModel.getLocationsWithTimeAvailability(); + + TravelTimesByAvailabilityWithMetadata result; + try { + result = mapService.getTravelTimeAndDistance(locations, optionsSupplier.getOptions(), availability); + } catch (TimefoldRuntimeException e) { + throw e; + } catch (Exception e) { + throw new TimefoldRuntimeException(ErrorCodes.MAP_SERVICE_UNKNOWN, + "Error getting travel time and distances from map service", e, false); + } + + for (Location location : locations) { + location.setTravelTimeMatrices(result.travelTimesByTimeframe(), result.timeframeIndexResolver()); + location.setDistanceMatrices(result.distancesByTimeframe(), result.timeframeIndexResolver()); + } + solverModel.setLocationsNotInMap(result.locationsNotInMap()); + return solverModel; + } + @Override public boolean accept(Object solverModel) { return solverModel instanceof LocationsAwareSolverModel; diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/model/TravelTimesByAvailabilityWithMetadata.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/model/TravelTimesByAvailabilityWithMetadata.java new file mode 100644 index 0000000000..627e839d38 --- /dev/null +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/api/model/TravelTimesByAvailabilityWithMetadata.java @@ -0,0 +1,24 @@ +package ai.timefold.solver.model.maps.service.client.api.model; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.function.ToIntFunction; + +import ai.timefold.solver.model.maps.api.DistanceMatrix; +import ai.timefold.solver.model.maps.api.model.Location; + +/** + * Return type for the availability-based travel time and distance fetch. Holds per-timeframe travel-time and distance + * matrices as plain arrays in a fixed order defined by the client's bucketing, plus a + * {@code ToIntFunction} that resolves an instant to an array index. Callers (typically the enricher) + * hand the arrays and the resolver to {@link Location} without ever seeing the bucketing details themselves. The + * solver-facing API is pure {@link OffsetDateTime} in, array index out. + *

+ * {@code locationsNotInMap} is the union of locations that fell out of the map across every timeframe call; the list + * preserves insertion order of the caller's original location list and contains no duplicates. + */ +public record TravelTimesByAvailabilityWithMetadata(DistanceMatrix[] travelTimesByTimeframe, + DistanceMatrix[] distancesByTimeframe, + List locationsNotInMap, + ToIntFunction timeframeIndexResolver) { +} diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java index 1b430a15c3..8b301a20b5 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceClientImpl.java @@ -14,12 +14,16 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.stream.Stream; import jakarta.inject.Inject; @@ -27,9 +31,13 @@ import ai.timefold.solver.model.maps.api.DistanceMatrix; import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; import ai.timefold.solver.model.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider; import ai.timefold.solver.model.maps.haversine.impl.HaversineWaypointsProvider; import ai.timefold.solver.model.maps.service.client.api.MapService; +import ai.timefold.solver.model.maps.service.client.api.model.TravelTimesByAvailabilityWithMetadata; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.Timeframe; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.TimeframeBucketing; import ai.timefold.solver.model.maps.service.client.impl.error.GoneRuntimeException; import ai.timefold.solver.model.maps.service.integration.internal.MapServiceOptions; import ai.timefold.solver.model.maps.service.integration.internal.model.IllegalDistanceResponseException; @@ -38,6 +46,7 @@ import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceWithMetadata; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.context.ManagedExecutor; import org.eclipse.microprofile.rest.client.inject.RestClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,23 +63,38 @@ public class MapServiceClientImpl implements MapService { private final MapServiceClient mapService; private final List converters; private final Optional fallbackEnabled; + private final boolean useTraffic; + private final Timeframe defaultTimeframe; private final MapServiceLocalHaversineImpl fallbackService; private final SingleItemCache travelTimeAndDistanceSingleItemCache; + private final SingleItemCache availabilityCache; + private final TimeframeBucketing timeframeBucketing; + private final ManagedExecutor managedExecutor; private final ObjectMapper mapper; @Inject public MapServiceClientImpl(@RestClient MapServiceClient mapService, @All List converters, @ConfigProperty(name = "ai.timefold.platform.map-service.enable-fallback") Optional fallbackEnabled, + @ConfigProperty(name = "ai.timefold.platform.map-service.use-traffic") Optional useTraffic, + @ConfigProperty( + name = "ai.timefold.platform.map-service.default-timeframe") Optional defaultTimeframeOverride, HaversineTravelTimeAndDistanceMatrixProvider travelTimeAndDistanceMatrixProvider, HaversineWaypointsProvider haversineWaypointsProvider, + TimeframeBucketing timeframeBucketing, + ManagedExecutor managedExecutor, ObjectMapper mapper) { this.mapService = mapService; this.converters = converters; this.fallbackEnabled = fallbackEnabled; + this.useTraffic = useTraffic.orElse(false); + this.timeframeBucketing = timeframeBucketing; + this.defaultTimeframe = resolveDefaultTimeframe(timeframeBucketing, defaultTimeframeOverride); + this.managedExecutor = managedExecutor; this.mapper = mapper; fallbackService = new MapServiceLocalHaversineImpl(travelTimeAndDistanceMatrixProvider, haversineWaypointsProvider); travelTimeAndDistanceSingleItemCache = new SingleItemCache<>(); + availabilityCache = new SingleItemCache<>(); } /** @@ -98,6 +122,13 @@ public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List Map optionsMap = MapServiceOptions.parse(options); String locationSetName = optionsMap.getOrDefault(MapServiceOptions.LOCATION_SET_NAME, null); + // Traffic-without-pruning path: when traffic is enabled platform-wide but the caller didn't pick a timeframe, + // append the bucketing's default so the maps service returns a single traffic-aware matrix instead of plain + // (timeframe-independent) data. The model keeps using location.getTravelTimeTo(other). + if (useTraffic && !optionsMap.containsKey(MapServiceOptions.TIMEFRAME)) { + options = MapServiceOptions.withOption(options, MapServiceOptions.TIMEFRAME, defaultTimeframe.name()); + } + if (locations.size() < 2) { LOGGER.info("The number of locations is {}, generating empty distance matrix", locations.size()); return generateZeroTravelTimeAndDistanceMatrixFromLocations(locations); @@ -168,6 +199,168 @@ public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List } + @Override + public TravelTimesByAvailabilityWithMetadata getTravelTimeAndDistance(List locations, String options, + Map> timeAvailability) { + // Build per-timeframe location subsets: a location is only included in timeframes it's actually available in, + // so the maps service doesn't compute cells we won't use. + Map> locationsByTimeframe = new LinkedHashMap<>(); + for (Map.Entry> entry : timeAvailability.entrySet()) { + for (TimeInterval interval : entry.getValue()) { + for (Timeframe timeframe : timeframeBucketing.timeframesOf(interval.from(), interval.to())) { + locationsByTimeframe.computeIfAbsent(timeframe, k -> new LinkedHashSet<>()).add(entry.getKey()); + } + } + } + if (locationsByTimeframe.isEmpty()) { + throw new IllegalArgumentException( + "timeAvailability contained no intervals; at least one is required to derive a timeframe."); + } + + String cacheId = String.valueOf(Objects.hash(new HashSet<>(locations), options, timeAvailability)); + if (availabilityCache.isInCache(cacheId)) { + LOGGER.info("Availability matrices in cache, returning from cache"); + return availabilityCache.get(); + } + + if (!useTraffic) { + LOGGER.info("Traffic disabled by platform configuration; using non-traffic matrix for availability request"); + TravelTimeAndDistanceWithMetadata plain = getTravelTimeAndDistance(locations, options); + Set notInMapSet = new HashSet<>(); + for (Integer idx : plain.locationsNotInMapIdx()) { + if (idx != null && idx >= 0 && idx < locations.size()) { + notInMapSet.add(locations.get(idx)); + } + } + List locationsNotInMap = locations.stream().filter(notInMapSet::contains).toList(); + TravelTimesByAvailabilityWithMetadata result = new TravelTimesByAvailabilityWithMetadata( + new DistanceMatrix[] { plain.travelTimeAndDistance().travelTime() }, + new DistanceMatrix[] { plain.travelTimeAndDistance().distance() }, + locationsNotInMap, + t -> 0); + availabilityCache.put(cacheId, result); + return result; + } + + AssembledTimeframedMatrices assembled; + try { + assembled = fetchBundledTimeframedMatrices(options, locationsByTimeframe, locations); + } catch (CompletionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (fallbackEnabled.orElse(false)) { + LOGGER.warn("Could not get travel time and distance using maps service, will fallback using Haversine.", cause); + return fallbackAvailabilityMatrices(locations, options, locationsByTimeframe); + } + if (cause instanceof RuntimeException re) { + throw re; + } + throw new IllegalStateException(cause); + } + + TravelTimesByAvailabilityWithMetadata result = new TravelTimesByAvailabilityWithMetadata( + assembled.travelTimesByTimeframe, + assembled.distancesByTimeframe, + assembled.locationsNotInMap, + timeframeBucketing::indexOf); + availabilityCache.put(cacheId, result); + LOGGER.info("Distance/time matrix calculation completed"); + return result; + } + + /** + * Fans out one bundled-response request per timeframe (no {@code annotation} option, so the server returns both + * travel-time and distance matrices). Each call sends only the locations available in its timeframe. A timeframe + * whose subset has fewer than two locations is short-circuited to a locally-generated zero matrix without a server + * round-trip. + *

+ * The produced {@code travelTimesByTimeframe} and {@code distancesByTimeframe} arrays are sized to the full + * {@code TimeframeBucketing.allTimeframes().size()}; cells for timeframes that aren't needed by the model's + * availability are left {@code null}. Solver-side lookups on those cells will throw through + * {@link Location#getTravelTimeTo(Location, OffsetDateTime)}. + *

+ * "Locations not in map" indices returned by each call are relative to that call's subset; the assembly step + * resolves them to {@link Location} instances, then filters {@code outerLocations} against that set to produce a + * final list in outer-list order with no duplicates. + */ + private AssembledTimeframedMatrices fetchBundledTimeframedMatrices(String options, + Map> locationsByTimeframe, + List outerLocations) { + List allTimeframes = timeframeBucketing.allTimeframes(); + int n = allTimeframes.size(); + LOGGER.info("Requesting up to {} bundled travel-time + distance matrix(es) concurrently... ", + locationsByTimeframe.size()); + + DistanceMatrix[] travelTimesByTimeframe = new DistanceMatrix[n]; + DistanceMatrix[] distancesByTimeframe = new DistanceMatrix[n]; + Set notInMapSet = new HashSet<>(); + + // Futures indexed by bucketing position; null means either "timeframe not needed" or "zero-matrix short-circuit". + CompletableFuture[] futures = new CompletableFuture[n]; + List[] subsets = new List[n]; + + for (int idx = 0; idx < n; idx++) { + Timeframe timeframe = allTimeframes.get(idx); + LinkedHashSet subsetRaw = locationsByTimeframe.get(timeframe); + if (subsetRaw == null) { + continue; // timeframe not needed by the model — leave array cells null + } + List subset = new ArrayList<>(subsetRaw); + subsets[idx] = subset; + if (subset.size() < 2) { + // Not enough locations to need a matrix — short-circuit to a zero matrix, skip the server call. + DistanceMatrix zero = zeroMatrixFor(subset); + travelTimesByTimeframe[idx] = zero; + distancesByTimeframe[idx] = zero; + continue; + } + String opts = MapServiceOptions.withOption(options, MapServiceOptions.TIMEFRAME, timeframe.name()); + futures[idx] = CompletableFuture.supplyAsync(() -> requestAndConvert(subset, opts), managedExecutor); + } + + CompletableFuture[] pending = Arrays.stream(futures).filter(Objects::nonNull) + .toArray(CompletableFuture[]::new); + if (pending.length > 0) { + CompletableFuture.allOf(pending).join(); + } + + for (int idx = 0; idx < n; idx++) { + CompletableFuture future = futures[idx]; + if (future == null) { + continue; // already filled (zero matrix) or not needed + } + TravelTimeAndDistanceWithMetadata response = future.resultNow(); + travelTimesByTimeframe[idx] = response.travelTimeAndDistance().travelTime(); + distancesByTimeframe[idx] = response.travelTimeAndDistance().distance(); + List subset = subsets[idx]; + for (int subsetIdx : response.locationsNotInMapIdx()) { + if (subsetIdx >= 0 && subsetIdx < subset.size()) { + notInMapSet.add(subset.get(subsetIdx)); + } + } + } + + // One pass over the canonical outer list gives outer-list order and dedup at the same time. + List locationsNotInMap = outerLocations.stream() + .filter(notInMapSet::contains) + .toList(); + return new AssembledTimeframedMatrices(travelTimesByTimeframe, distancesByTimeframe, locationsNotInMap); + } + + private record AssembledTimeframedMatrices(DistanceMatrix[] travelTimesByTimeframe, + DistanceMatrix[] distancesByTimeframe, + List locationsNotInMap) { + } + + private DistanceMatrix zeroMatrixFor(List locations) { + DistanceMatrix matrix = DistanceMatrix.getInstance(locations.size()); + for (Location from : locations) { + for (Location to : locations) { + matrix.put(from, to, 0); + } + } + return matrix; + } + @Override public List getWaypoints(List locations, String options) { if (locations.size() < 2) { @@ -315,6 +508,28 @@ private TravelTimeAndDistanceWithMetadata processUpdateAndStoreInCache(Response } } + private TravelTimeAndDistanceWithMetadata requestAndConvert(List locations, String options) { + Response response = mapService.getTravelTimeAndDistance(locations, options); + String provider = response.getHeaderString(X_MAPS_PROVIDER_HEADER); + String locationsNotInMapString = response.getHeaderString(X_MAPS_LOCATIONS_NOT_IN_MAP); + List chunkBytes = parseChunkBytesString(response.getHeaderString(X_MAPS_RESPONSE_CHUNK_BYTES)); + List metadataBytes = parseChunkBytesString(response.getHeaderString(X_MAPS_LOCATIONS_CHUNK_BYTES)); + + List locationsNotInMap = new ArrayList<>(); + if (locationsNotInMapString != null && !locationsNotInMapString.isEmpty()) { + locationsNotInMap = Arrays.stream(locationsNotInMapString.split(",")).map(Integer::valueOf).toList(); + } + + InputStream data = response.readEntity(InputStream.class); + List responseLocations = readLocationsFromInputStream(data, metadataBytes); + + if (provider == null) { + throw new IllegalArgumentException("No provider found to convert travel time and distance response."); + } + + return convertResponse(provider, chunkBytes, responseLocations, data, locationsNotInMap); + } + private TravelTimeAndDistanceWithMetadata convertResponse(String provider, List chunkBytes, List locations, InputStream data, List locationsNotInMap) { for (TravelTimeAndDistanceConverter converter : converters) { @@ -359,6 +574,31 @@ private TravelTimeAndDistanceWithMetadata generateZeroTravelTimeAndDistanceMatri return new TravelTimeAndDistanceWithMetadata(new TravelTimeAndDistance(matrix, matrix), new ArrayList<>()); } + private TravelTimesByAvailabilityWithMetadata fallbackAvailabilityMatrices(List locations, String options, + Map> locationsByTimeframe) { + TravelTimeAndDistanceWithMetadata fallback = fallbackService.getTravelTimeAndDistance(locations, options); + List allTimeframes = timeframeBucketing.allTimeframes(); + DistanceMatrix[] travelTimesByTimeframe = new DistanceMatrix[allTimeframes.size()]; + DistanceMatrix[] distancesByTimeframe = new DistanceMatrix[allTimeframes.size()]; + DistanceMatrix fallbackTravelTime = fallback.travelTimeAndDistance().travelTime(); + DistanceMatrix fallbackDistance = fallback.travelTimeAndDistance().distance(); + for (int idx = 0; idx < allTimeframes.size(); idx++) { + if (locationsByTimeframe.containsKey(allTimeframes.get(idx))) { + travelTimesByTimeframe[idx] = fallbackTravelTime; + distancesByTimeframe[idx] = fallbackDistance; + } + } + Set notInMapSet = new HashSet<>(); + for (int idx : fallback.locationsNotInMapIdx()) { + if (idx >= 0 && idx < locations.size()) { + notInMapSet.add(locations.get(idx)); + } + } + List locationsNotInMap = locations.stream().filter(notInMapSet::contains).toList(); + return new TravelTimesByAvailabilityWithMetadata(travelTimesByTimeframe, distancesByTimeframe, + locationsNotInMap, timeframeBucketing::indexOf); + } + private List readLocationsFromInputStream(InputStream stream, List chunkBytes) { List locations = new ArrayList<>(); try { @@ -383,4 +623,18 @@ private void assertLocationsAreInCache(List locations) { throw new IllegalArgumentException("Locations received do not correspond to location set"); } } + + private Timeframe resolveDefaultTimeframe(TimeframeBucketing bucketing, Optional override) { + if (override.isEmpty() || override.get().isBlank()) { + return bucketing.defaultTimeframe(); + } + String requested = override.get(); + return bucketing.allTimeframes().stream() + .filter(t -> t.name().equalsIgnoreCase(requested)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + ("Configured default timeframe '%s' is not one of the supported timeframes %s. " + + "Check the ai.timefold.platform.map-service.default-timeframe property.") + .formatted(requested, bucketing.allTimeframes()))); + } } diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceLocalHaversineImpl.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceLocalHaversineImpl.java index 4067d5d42b..01b5d9c164 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceLocalHaversineImpl.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceLocalHaversineImpl.java @@ -2,14 +2,22 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import jakarta.inject.Inject; +import ai.timefold.solver.model.maps.api.DistanceMatrix; import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; import ai.timefold.solver.model.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider; import ai.timefold.solver.model.maps.haversine.impl.HaversineWaypointsProvider; import ai.timefold.solver.model.maps.service.client.api.MapService; +import ai.timefold.solver.model.maps.service.client.api.model.TravelTimesByAvailabilityWithMetadata; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.SingleTimeframeBucketing; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.TimeframeBucketing; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistance; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceWithMetadata; import ai.timefold.solver.model.maps.service.integration.internal.provider.WaypointsProvider; @@ -32,6 +40,27 @@ public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List return new TravelTimeAndDistanceWithMetadata(travelTimeAndDistance, new ArrayList<>()); } + @Override + public TravelTimesByAvailabilityWithMetadata getTravelTimeAndDistance(List locations, String options, + Map> timeAvailability) { + // Haversine is timeframe-independent by definition, so we use a single-bucket bucketing: a single entry in the + // arrays covers every lookup, and the index resolver always returns 0. + TimeframeBucketing bucketing = new SingleTimeframeBucketing(); + TravelTimeAndDistanceWithMetadata result = getTravelTimeAndDistance(locations, options); + DistanceMatrix[] travelTimesByTimeframe = { result.travelTimeAndDistance().travelTime() }; + DistanceMatrix[] distancesByTimeframe = { result.travelTimeAndDistance().distance() }; + + Set notInMapSet = new HashSet<>(); + for (int index : result.locationsNotInMapIdx()) { + if (index >= 0 && index < locations.size()) { + notInMapSet.add(locations.get(index)); + } + } + List locationsNotInMap = locations.stream().filter(notInMapSet::contains).toList(); + return new TravelTimesByAvailabilityWithMetadata(travelTimesByTimeframe, distancesByTimeframe, + locationsNotInMap, bucketing::indexOf); + } + @Override public List getWaypoints(List locations, String options) { return waypointsProvider.getWaypoints(locations, Collections.emptyMap()); diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceProducer.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceProducer.java index f2542b1091..35cab6cc81 100644 --- a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceProducer.java +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/MapServiceProducer.java @@ -10,9 +10,11 @@ import ai.timefold.solver.model.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider; import ai.timefold.solver.model.maps.haversine.impl.HaversineWaypointsProvider; import ai.timefold.solver.model.maps.service.client.api.MapService; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.TimeframeBucketing; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceConverter; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.context.ManagedExecutor; import org.eclipse.microprofile.rest.client.inject.RestClient; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,6 +30,10 @@ public class MapServiceProducer { private final MapServiceClient mapService; private final List converters; private final Optional fallbackEnabled; + private final Optional useTraffic; + private final Optional defaultTimeframeOverride; + private final TimeframeBucketing timeframeBucketing; + private final ManagedExecutor managedExecutor; private final ObjectMapper mapper; @Inject @@ -38,6 +44,11 @@ public MapServiceProducer( @RestClient MapServiceClient mapService, @All List converters, @ConfigProperty(name = "ai.timefold.platform.map-service.enable-fallback") Optional fallbackEnabled, + @ConfigProperty(name = "ai.timefold.platform.map-service.use-traffic") Optional useTraffic, + @ConfigProperty( + name = "ai.timefold.platform.map-service.default-timeframe") Optional defaultTimeframeOverride, + TimeframeBucketing timeframeBucketing, + ManagedExecutor managedExecutor, ObjectMapper mapper) { this.useRemote = useRemote; this.travelTimeAndDistanceProvider = travelTimeAndDistanceProvider; @@ -45,14 +56,18 @@ public MapServiceProducer( this.mapService = mapService; this.converters = converters; this.fallbackEnabled = fallbackEnabled; + this.useTraffic = useTraffic; + this.defaultTimeframeOverride = defaultTimeframeOverride; + this.timeframeBucketing = timeframeBucketing; + this.managedExecutor = managedExecutor; this.mapper = mapper; } @Produces public MapService mapServiceProducer() { if (useRemote) { - return new MapServiceClientImpl(mapService, converters, fallbackEnabled, travelTimeAndDistanceProvider, - waypointsProvider, mapper); + return new MapServiceClientImpl(mapService, converters, fallbackEnabled, useTraffic, defaultTimeframeOverride, + travelTimeAndDistanceProvider, waypointsProvider, timeframeBucketing, managedExecutor, mapper); } return new MapServiceLocalHaversineImpl(travelTimeAndDistanceProvider, waypointsProvider); } diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/SingleTimeframeBucketing.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/SingleTimeframeBucketing.java new file mode 100644 index 0000000000..ae6779207d --- /dev/null +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/SingleTimeframeBucketing.java @@ -0,0 +1,44 @@ +package ai.timefold.solver.model.maps.service.client.impl.bucketing; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; + +/** + * Bucketing that maps every {@link OffsetDateTime} to a single timeframe. Intended for timeframe-independent providers + * (for example, Haversine) where travel time and distance do not vary with time of day — the map service still needs + * to return a matrix per timeframe key, so one key covers every lookup and {@link #indexOf(OffsetDateTime)} always + * returns 0. + */ +public final class SingleTimeframeBucketing implements TimeframeBucketing { + + public static final Timeframe DEFAULT = new Timeframe("default"); + + private static final List ALL = List.of(DEFAULT); + + @Override + public Timeframe timeframeOf(OffsetDateTime time) { + return DEFAULT; + } + + @Override + public int indexOf(OffsetDateTime time) { + return 0; + } + + @Override + public List allTimeframes() { + return ALL; + } + + @Override + public Set timeframesOf(OffsetDateTime from, OffsetDateTime to) { + return Set.of(DEFAULT); + } + + @Override + public Timeframe defaultTimeframe() { + return DEFAULT; + } + +} diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/StaticDaypartBucketing.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/StaticDaypartBucketing.java new file mode 100644 index 0000000000..45ae60b516 --- /dev/null +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/StaticDaypartBucketing.java @@ -0,0 +1,88 @@ +package ai.timefold.solver.model.maps.service.client.impl.bucketing; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Default v1 bucketing: splits the day into morning (06:00 inclusive – 12:00 exclusive), afternoon (12:00 – 18:00) and + * night (18:00 – 06:00 the next day). Comparison is done in each input's own offset, so callers control the time zone + * by choosing the offset of the {@link OffsetDateTime} they pass in. + *

+ * The ordering returned by {@link #allTimeframes()} — morning (0), afternoon (1), night (2) — is the stable convention + * the maps service client uses for its per-timeframe arrays. + */ +@ApplicationScoped +public class StaticDaypartBucketing implements TimeframeBucketing { + + public static final Timeframe MORNING = new Timeframe("morning"); + public static final Timeframe AFTERNOON = new Timeframe("afternoon"); + public static final Timeframe NIGHT = new Timeframe("night"); + + private static final int MORNING_INDEX = 0; + private static final int AFTERNOON_INDEX = 1; + private static final int NIGHT_INDEX = 2; + + private static final List ALL = List.of(MORNING, AFTERNOON, NIGHT); + private static final int[] BUCKET_HOURS = { 6, 12, 18 }; + + @Override + public Timeframe timeframeOf(OffsetDateTime time) { + return ALL.get(indexOf(time)); + } + + @Override + public int indexOf(OffsetDateTime time) { + int hour = time.getHour(); + if (hour >= 6 && hour < 12) { + return MORNING_INDEX; + } else if (hour >= 12 && hour < 18) { + return AFTERNOON_INDEX; + } else { + return NIGHT_INDEX; + } + } + + @Override + public List allTimeframes() { + return ALL; + } + + @Override + public Timeframe defaultTimeframe() { + return MORNING; + } + + @Override + public Set timeframesOf(OffsetDateTime from, OffsetDateTime to) { + Set result = new LinkedHashSet<>(); + result.add(timeframeOf(from)); + OffsetDateTime boundary = nextBucketBoundaryAfter(from); + while (boundary.isBefore(to)) { + result.add(timeframeOf(boundary)); + boundary = nextBucketBoundaryAfter(boundary); + } + return result; + } + + // Returns the first bucket boundary (06:00, 12:00, or 18:00 on the same date, or 06:00 next day) + // that is strictly after t, using t's own offset. + private OffsetDateTime nextBucketBoundaryAfter(OffsetDateTime t) { + LocalDate date = t.toLocalDate(); + ZoneOffset offset = t.getOffset(); + for (int bucketHours : BUCKET_HOURS) { + OffsetDateTime candidate = OffsetDateTime.of(date, LocalTime.of(bucketHours, 0), offset); + if (candidate.isAfter(t)) { + return candidate; + } + } + return OffsetDateTime.of(date.plusDays(1), LocalTime.of(6, 0), offset); + } + +} diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/Timeframe.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/Timeframe.java new file mode 100644 index 0000000000..357548af1a --- /dev/null +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/Timeframe.java @@ -0,0 +1,19 @@ +package ai.timefold.solver.model.maps.service.client.impl.bucketing; + +import java.util.Objects; + +/** + * Type-safe identifier for a single bucket produced by a {@link TimeframeBucketing}. Wrapping the name as a dedicated + * type prevents arbitrary strings from sneaking into the {@code Map} subset keys built by the maps + * service client. The underlying string is only unwrapped at the wire boundary, where the maps service expects an + * option of the form {@code timeframe=morning}. + */ +public record Timeframe(String name) { + + public Timeframe { + Objects.requireNonNull(name, "Timeframe name must not be null."); + if (name.isBlank()) { + throw new IllegalArgumentException("Timeframe name must not be blank."); + } + } +} diff --git a/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/TimeframeBucketing.java b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/TimeframeBucketing.java new file mode 100644 index 0000000000..40904786f5 --- /dev/null +++ b/model/maps/service-client/src/main/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/TimeframeBucketing.java @@ -0,0 +1,55 @@ +package ai.timefold.solver.model.maps.service.client.impl.bucketing; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; + +/** + * Maps an {@link OffsetDateTime} to a {@link Timeframe} and to a zero-based index into the ordered list + * returned by {@link #allTimeframes()}. The map service client uses {@link Timeframe} for typed subset keys and the + * wire option ({@code timeframe=morning}), while the solver-facing hot path uses the index for an O(1) array lookup. + *

+ * Bucketing is an implementation detail of the maps service client; it is not exposed by {@code maps-api} or + * {@code service-integration}. {@code Location} only receives a {@code ToIntFunction} derived from + * {@link #indexOf(OffsetDateTime)}. + *

+ * Implementations must be deterministic, stateless, and thread-safe. The invariant + * {@code allTimeframes().get(indexOf(t)).equals(timeframeOf(t))} must hold for every {@code t}. + */ +public interface TimeframeBucketing { + + /** + * @param time the instant for which to determine the timeframe; never {@code null} + * @return the timeframe this instant belongs to; never {@code null} + */ + Timeframe timeframeOf(OffsetDateTime time); + + /** + * @param time the instant for which to determine the timeframe; never {@code null} + * @return the zero-based position of this instant's timeframe in {@link #allTimeframes()}. + */ + int indexOf(OffsetDateTime time); + + /** + * @return every timeframe this bucketing can produce, in a stable order. The index of each entry in this list + * matches the value returned by {@link #indexOf(OffsetDateTime)} for any instant that maps to it. + */ + List allTimeframes(); + + /** + * @param from the start of the interval (inclusive); never {@code null} + * @param to the end of the interval (exclusive); never {@code null}, must not be before {@code from} + * @return the set of timeframes that the half-open interval {@code [from, to)} overlaps with; + * never empty — at minimum the timeframe of {@code from} is always included + */ + Set timeframesOf(OffsetDateTime from, OffsetDateTime to); + + /** + * @return the timeframe used when a query doesn't carry a timestamp (e.g. the traffic-without-pruning path, where + * the maps service client appends this timeframe as an option to fetch a single traffic-aware matrix on the + * model's behalf). Implementations should pick a neutral representative bucket (e.g. mid-day) rather than + * an edge case. Never {@code null}; must always be a member of {@link #allTimeframes()}. + */ + Timeframe defaultTimeframe(); + +} diff --git a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java new file mode 100644 index 0000000000..98cf95aa05 --- /dev/null +++ b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java @@ -0,0 +1,288 @@ +package ai.timefold.solver.model.maps.service.client.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.ToIntFunction; + +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.score.HardSoftScore; +import ai.timefold.solver.model.definition.internal.error.TimefoldRuntimeException; +import ai.timefold.solver.model.maps.api.DistanceMatrix; +import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; +import ai.timefold.solver.model.maps.api.model.travel.TravelDistance; +import ai.timefold.solver.model.maps.api.model.travel.TravelTime; +import ai.timefold.solver.model.maps.service.client.api.model.TravelTimesByAvailabilityWithMetadata; +import ai.timefold.solver.model.maps.service.client.impl.MapServiceOptionsSupplier; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.StaticDaypartBucketing; +import ai.timefold.solver.model.maps.service.integration.api.LocationsAndTrafficAwareSolverModel; +import ai.timefold.solver.model.maps.service.integration.api.LocationsAwareSolverModel; +import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistance; +import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceWithMetadata; + +import org.junit.jupiter.api.Test; + +class TravelTimeMatrixEnricherTrafficTest { + + private static final OffsetDateTime MORNING_AT = OffsetDateTime.of(2024, 1, 1, 8, 0, 0, 0, ZoneOffset.UTC); + private static final OffsetDateTime AFTERNOON_AT = OffsetDateTime.of(2024, 1, 1, 16, 0, 0, 0, ZoneOffset.UTC); + + private final MapServiceOptionsSupplier optionsSupplier = new MapServiceOptionsSupplier( + Optional.empty(), Optional.empty(), Optional.of(1000.0), + Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); + + @Test + void stampsMatricesAndResolverOntoEveryLocationForTrafficAwareModel() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + + DistanceMatrix morningTravel = matrixOf(l1, l2, 100L); + DistanceMatrix morningDistance = matrixOf(l1, l2, 1_000L); + DistanceMatrix afternoonTravel = matrixOf(l1, l2, 500L); + DistanceMatrix afternoonDistance = matrixOf(l1, l2, 1_200L); + + StaticDaypartBucketing bucketing = new StaticDaypartBucketing(); + int n = bucketing.allTimeframes().size(); + DistanceMatrix[] travelTimesByTimeframe = new DistanceMatrix[n]; + DistanceMatrix[] distancesByTimeframe = new DistanceMatrix[n]; + int morningIdx = bucketing.indexOf(MORNING_AT); + int afternoonIdx = bucketing.indexOf(AFTERNOON_AT); + travelTimesByTimeframe[morningIdx] = morningTravel; + distancesByTimeframe[morningIdx] = morningDistance; + travelTimesByTimeframe[afternoonIdx] = afternoonTravel; + distancesByTimeframe[afternoonIdx] = afternoonDistance; + ToIntFunction resolver = bucketing::indexOf; + + StubMapService stub = new StubMapService(new TravelTimesByAvailabilityWithMetadata( + travelTimesByTimeframe, distancesByTimeframe, List.of(), resolver), null); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(stub, optionsSupplier); + + Map> availability = new LinkedHashMap<>(); + // Single interval spanning morning and afternoon covers both timeframes + availability.put(l1, List.of(new TimeInterval(MORNING_AT, AFTERNOON_AT))); + availability.put(l2, List.of(new TimeInterval(MORNING_AT, AFTERNOON_AT))); + + LocationsAwareSolverModel enriched = enricher.enrich(new StubTrafficModel(availability)); + + assertThat(stub.trafficInvocationCount.get()).isEqualTo(1); + assertThat(stub.singleInvocationCount.get()).isZero(); + assertThat(stub.lastAvailability).isEqualTo(availability); + + assertThat(l1.getTravelTimeTo(l2, MORNING_AT)).isEqualTo(TravelTime.of(100L)); + assertThat(l1.getTravelTimeTo(l2, AFTERNOON_AT)).isEqualTo(TravelTime.of(500L)); + assertThat(l1.getDistanceTo(l2, MORNING_AT)).isEqualTo(TravelDistance.of(1_000L)); + assertThat(l1.getDistanceTo(l2, AFTERNOON_AT)).isEqualTo(TravelDistance.of(1_200L)); + + assertThat(l2.getTravelTimeTo(l1, MORNING_AT)).isEqualTo(TravelTime.of(100L)); + assertThat(l2.getDistanceTo(l1, AFTERNOON_AT)).isEqualTo(TravelDistance.of(1_200L)); + + assertThat(enriched.getLocationsNotInMap()).isEmpty(); + } + + @Test + void regularModelUsesSingleMatrix() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + DistanceMatrix travel = matrixOf(l1, l2, 75L); + DistanceMatrix distance = matrixOf(l1, l2, 750L); + StubMapService stub = new StubMapService(null, + new TravelTimeAndDistanceWithMetadata(new TravelTimeAndDistance(travel, distance), List.of())); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(stub, optionsSupplier); + + enricher.enrich(new StubLocationsModel(List.of(l1, l2))); + + assertThat(stub.singleInvocationCount.get()).isEqualTo(1); + assertThat(stub.trafficInvocationCount.get()).isZero(); + assertThat(l1.getTravelTimeTo(l2)).isEqualTo(TravelTime.of(75L)); + assertThat(l1.getDistanceTo(l2)).isEqualTo(TravelDistance.of(750L)); + } + + @Test + void trafficAwareModelIsModelLevelSwitch() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + StaticDaypartBucketing bucketing = new StaticDaypartBucketing(); + DistanceMatrix[] travelTimesByTimeframe = new DistanceMatrix[bucketing.allTimeframes().size()]; + DistanceMatrix[] distancesByTimeframe = new DistanceMatrix[bucketing.allTimeframes().size()]; + int morningIdx = bucketing.indexOf(MORNING_AT); + travelTimesByTimeframe[morningIdx] = matrixOf(l1, l2, 100L); + distancesByTimeframe[morningIdx] = matrixOf(l1, l2, 1_000L); + + StubMapService stub = new StubMapService(new TravelTimesByAvailabilityWithMetadata( + travelTimesByTimeframe, distancesByTimeframe, List.of(), bucketing::indexOf), null); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(stub, optionsSupplier); + + Map> availability = + Map.of(l1, List.of(new TimeInterval(MORNING_AT, MORNING_AT)), + l2, List.of(new TimeInterval(MORNING_AT, MORNING_AT))); + enricher.enrich(new StubTrafficModel(availability)); + + assertThat(stub.trafficInvocationCount.get()).isEqualTo(1); + assertThat(stub.lastAvailability).isEqualTo(availability); + assertThat(l1.getTravelTimeTo(l2, MORNING_AT)).isEqualTo(TravelTime.of(100L)); + } + + @Test + void acceptsLocationsAwareSolverModel() { + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(new StubMapService(null, null), optionsSupplier); + + assertThat(enricher.accept(new StubLocationsModel(List.of()))).isTrue(); + assertThat(enricher.accept(new Object())).isFalse(); + } + + @Test + void wrapsNonTimefoldExceptionFromMapService() { + MapService failing = new FailingMapService(); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(failing, optionsSupplier); + + Location l1 = new Location(0, 0); + Map> availability = new LinkedHashMap<>(); + availability.put(l1, List.of(new TimeInterval(MORNING_AT, MORNING_AT))); + + assertThatThrownBy(() -> enricher.enrich(new StubTrafficModel(availability))) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining("Error getting travel time and distances"); + } + + private static DistanceMatrix matrixOf(Location from, Location to, long value) { + DistanceMatrix matrix = DistanceMatrix.getInstance(2); + matrix.put(from, to, value); + matrix.put(to, from, value); + return matrix; + } + + private static final class StubMapService implements MapService { + + private final TravelTimesByAvailabilityWithMetadata trafficResult; + private final TravelTimeAndDistanceWithMetadata singleResult; + private final AtomicInteger trafficInvocationCount = new AtomicInteger(0); + private final AtomicInteger singleInvocationCount = new AtomicInteger(0); + private Map> lastAvailability; + + StubMapService(TravelTimesByAvailabilityWithMetadata trafficResult, + TravelTimeAndDistanceWithMetadata singleResult) { + this.trafficResult = trafficResult; + this.singleResult = singleResult; + } + + @Override + public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List locations, String options) { + singleInvocationCount.incrementAndGet(); + if (singleResult == null) { + throw new UnsupportedOperationException("single matrix result not configured"); + } + return singleResult; + } + + @Override + public TravelTimesByAvailabilityWithMetadata getTravelTimeAndDistance(List locations, String options, + Map> timeAvailability) { + trafficInvocationCount.incrementAndGet(); + lastAvailability = timeAvailability; + if (trafficResult == null) { + throw new UnsupportedOperationException("traffic matrix result not configured"); + } + return trafficResult; + } + + @Override + public List getWaypoints(List locations, String options) { + throw new UnsupportedOperationException("not used in this test"); + } + + @Override + public List getLocationsOutOfMap(List locations, String options) { + throw new UnsupportedOperationException("not used in this test"); + } + } + + private static final class FailingMapService implements MapService { + + @Override + public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List locations, String options) { + throw new UnsupportedOperationException("not used in this test"); + } + + @Override + public TravelTimesByAvailabilityWithMetadata getTravelTimeAndDistance(List locations, String options, + Map> timeAvailability) { + throw new RuntimeException("boom"); + } + + @Override + public List getWaypoints(List locations, String options) { + throw new UnsupportedOperationException("not used in this test"); + } + + @Override + public List getLocationsOutOfMap(List locations, String options) { + throw new UnsupportedOperationException("not used in this test"); + } + } + + private static class StubLocationsModel implements LocationsAwareSolverModel { + + private final List locations; + private List notInMap; + + StubLocationsModel(List locations) { + this.locations = locations; + } + + @Override + public List getLocations() { + return locations; + } + + @Override + public Optional getLocationSetName() { + return Optional.empty(); + } + + @Override + public void setLocationsNotInMap(List locationsNotInMap) { + this.notInMap = locationsNotInMap; + } + + @Override + public List getLocationsNotInMap() { + return notInMap; + } + + @Override + public HardSoftScore getScore() { + return null; + } + + @Override + public ConstraintWeightOverrides getConstraintWeightOverrides() { + return null; + } + } + + private static final class StubTrafficModel extends StubLocationsModel + implements LocationsAndTrafficAwareSolverModel { + + private final Map> availability; + + StubTrafficModel(Map> availability) { + super(List.copyOf(availability.keySet())); + this.availability = availability; + } + + @Override + public Map> getLocationsWithTimeAvailability() { + return availability; + } + } + +} diff --git a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/StaticDaypartBucketingTest.java b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/StaticDaypartBucketingTest.java new file mode 100644 index 0000000000..e211234868 --- /dev/null +++ b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/impl/bucketing/StaticDaypartBucketingTest.java @@ -0,0 +1,101 @@ +package ai.timefold.solver.model.maps.service.client.impl.bucketing; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.Test; + +class StaticDaypartBucketingTest { + + private final StaticDaypartBucketing bucketing = new StaticDaypartBucketing(); + + @Test + void boundariesAreHalfOpen() { + // Night: [00:00, 06:00) and [18:00, 24:00) + assertThat(bucketing.timeframeOf(at(0, 0))).isEqualTo(StaticDaypartBucketing.NIGHT); + assertThat(bucketing.timeframeOf(at(5, 59))).isEqualTo(StaticDaypartBucketing.NIGHT); + assertThat(bucketing.timeframeOf(at(18, 0))).isEqualTo(StaticDaypartBucketing.NIGHT); + assertThat(bucketing.timeframeOf(at(23, 59))).isEqualTo(StaticDaypartBucketing.NIGHT); + // Morning: [06:00, 12:00) + assertThat(bucketing.timeframeOf(at(6, 0))).isEqualTo(StaticDaypartBucketing.MORNING); + assertThat(bucketing.timeframeOf(at(11, 59))).isEqualTo(StaticDaypartBucketing.MORNING); + // Afternoon: [12:00, 18:00) + assertThat(bucketing.timeframeOf(at(12, 0))).isEqualTo(StaticDaypartBucketing.AFTERNOON); + assertThat(bucketing.timeframeOf(at(17, 59))).isEqualTo(StaticDaypartBucketing.AFTERNOON); + } + + @Test + void bucketsAreComparedInInputsOwnOffset() { + // 10:00 UTC = 12:00 in +02 — different buckets, because comparison uses the local hour. + OffsetDateTime utc = OffsetDateTime.of(2024, 1, 1, 10, 0, 0, 0, ZoneOffset.UTC); + OffsetDateTime plus2 = OffsetDateTime.of(2024, 1, 1, 12, 0, 0, 0, ZoneOffset.ofHours(2)); + assertThat(bucketing.timeframeOf(utc)).isEqualTo(StaticDaypartBucketing.MORNING); + assertThat(bucketing.timeframeOf(plus2)).isEqualTo(StaticDaypartBucketing.AFTERNOON); + } + + @Test + void allTimeframesReturnsExpectedKeys() { + assertThat(bucketing.allTimeframes()).containsExactly( + StaticDaypartBucketing.MORNING, + StaticDaypartBucketing.AFTERNOON, + StaticDaypartBucketing.NIGHT); + } + + @Test + void indexOfMatchesAllTimeframesOrder() { + assertThat(bucketing.indexOf(at(8, 0))).isEqualTo(0); // morning + assertThat(bucketing.indexOf(at(14, 0))).isEqualTo(1); // afternoon + assertThat(bucketing.indexOf(at(22, 0))).isEqualTo(2); // night + // Invariant: allTimeframes().get(indexOf(t)) == timeframeOf(t) + for (int h = 0; h < 24; h++) { + OffsetDateTime t = at(h, 0); + assertThat(bucketing.allTimeframes().get(bucketing.indexOf(t))) + .isEqualTo(bucketing.timeframeOf(t)); + } + } + + @Test + void timeframesOf_narrowIntervalWithinOneBucket() { + // [09:00, 11:00) — entirely within MORNING + assertThat(bucketing.timeframesOf(at(9, 0), at(11, 0))) + .containsExactly(StaticDaypartBucketing.MORNING); + } + + @Test + void timeframesOf_intervalSpanningTwoBuckets() { + // [09:00, 14:00) — spans MORNING and AFTERNOON (crosses 12:00 boundary) + assertThat(bucketing.timeframesOf(at(9, 0), at(14, 0))) + .containsExactlyInAnyOrder(StaticDaypartBucketing.MORNING, StaticDaypartBucketing.AFTERNOON); + } + + @Test + void timeframesOf_toExactlyOnBoundaryIsExclusive() { + // [09:00, 18:00) — to=18:00 is exclusive, so NIGHT bucket is NOT included + assertThat(bucketing.timeframesOf(at(9, 0), at(18, 0))) + .containsExactlyInAnyOrder(StaticDaypartBucketing.MORNING, StaticDaypartBucketing.AFTERNOON); + } + + @Test + void timeframesOf_toJustPastBoundaryIncludesNextBucket() { + // [09:00, 18:01) — just past 18:00, so NIGHT bucket IS included + assertThat(bucketing.timeframesOf(at(9, 0), at(18, 1))) + .containsExactlyInAnyOrder( + StaticDaypartBucketing.MORNING, + StaticDaypartBucketing.AFTERNOON, + StaticDaypartBucketing.NIGHT); + } + + @Test + void timeframesOf_zeroLengthIntervalCoversFromBucket() { + // [12:00, 12:00) — zero-length, but still covers AFTERNOON (from's bucket) + assertThat(bucketing.timeframesOf(at(12, 0), at(12, 0))) + .containsExactly(StaticDaypartBucketing.AFTERNOON); + } + + private static OffsetDateTime at(int hour, int minute) { + return OffsetDateTime.of(2024, 1, 1, hour, minute, 0, 0, ZoneOffset.UTC); + } + +} diff --git a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceTestWrapper.java b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceTestWrapper.java index 970e172bc3..46ff284287 100644 --- a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceTestWrapper.java +++ b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceTestWrapper.java @@ -1,9 +1,12 @@ package ai.timefold.solver.model.maps.service.client.util; import java.util.List; +import java.util.Map; import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; import ai.timefold.solver.model.maps.service.client.api.MapService; +import ai.timefold.solver.model.maps.service.client.api.model.TravelTimesByAvailabilityWithMetadata; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceWithMetadata; public class MapServiceTestWrapper implements MapService { @@ -21,6 +24,12 @@ public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List return delegate.getTravelTimeAndDistance(locations, options); } + @Override + public TravelTimesByAvailabilityWithMetadata getTravelTimeAndDistance(List locations, String options, + Map> timeAvailability) { + return delegate.getTravelTimeAndDistance(locations, options, timeAvailability); + } + @Override public List getWaypoints(List locations, String options) { mapServiceInvocationCounter.incrementWaypointsInvocationCounter(); diff --git a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceWithWrapperProducer.java b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceWithWrapperProducer.java index 77239007ee..c5badbbf55 100644 --- a/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceWithWrapperProducer.java +++ b/model/maps/service-client/src/test/java/ai/timefold/solver/model/maps/service/client/util/MapServiceWithWrapperProducer.java @@ -15,9 +15,11 @@ import ai.timefold.solver.model.maps.service.client.impl.MapServiceClient; import ai.timefold.solver.model.maps.service.client.impl.MapServiceClientImpl; import ai.timefold.solver.model.maps.service.client.impl.MapServiceLocalHaversineImpl; +import ai.timefold.solver.model.maps.service.client.impl.bucketing.TimeframeBucketing; import ai.timefold.solver.model.maps.service.integration.internal.model.TravelTimeAndDistanceConverter; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.context.ManagedExecutor; import org.eclipse.microprofile.rest.client.inject.RestClient; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,6 +37,8 @@ public class MapServiceWithWrapperProducer { private final MapServiceClient mapService; private final List converters; private final Optional fallbackEnabled; + private final TimeframeBucketing timeframeBucketing; + private final ManagedExecutor managedExecutor; private final ObjectMapper mapper; private final MapServiceInvocationCounter mapServiceInvocationCounter; @@ -46,6 +50,8 @@ public MapServiceWithWrapperProducer( @RestClient MapServiceClient mapService, @All List converters, @ConfigProperty(name = "ai.timefold.platform.map-service.enable-fallback") Optional fallbackEnabled, + TimeframeBucketing timeframeBucketing, + ManagedExecutor managedExecutor, ObjectMapper mapper, MapServiceInvocationCounter mapServiceInvocationCounter) { this.useRemote = useRemote; @@ -54,6 +60,8 @@ public MapServiceWithWrapperProducer( this.mapService = mapService; this.converters = converters; this.fallbackEnabled = fallbackEnabled; + this.timeframeBucketing = timeframeBucketing; + this.managedExecutor = managedExecutor; this.mapServiceInvocationCounter = mapServiceInvocationCounter; this.mapper = mapper; } @@ -63,8 +71,9 @@ public MapService mapServiceProducer() { MapService mapService; if (useRemote) { - mapService = new MapServiceClientImpl(this.mapService, converters, fallbackEnabled, travelTimeAndDistanceProvider, - waypointsProvider, mapper); + mapService = new MapServiceClientImpl(this.mapService, converters, fallbackEnabled, Optional.empty(), + Optional.empty(), travelTimeAndDistanceProvider, waypointsProvider, timeframeBucketing, + managedExecutor, mapper); } else { mapService = new MapServiceLocalHaversineImpl(travelTimeAndDistanceProvider, waypointsProvider); } diff --git a/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/api/LocationsAndTrafficAwareSolverModel.java b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/api/LocationsAndTrafficAwareSolverModel.java new file mode 100644 index 0000000000..c0965739dd --- /dev/null +++ b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/api/LocationsAndTrafficAwareSolverModel.java @@ -0,0 +1,37 @@ +package ai.timefold.solver.model.maps.service.integration.api; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.model.maps.api.model.Location; +import ai.timefold.solver.model.maps.api.model.TimeInterval; + +/** + * A solver model whose locations need travel-time matrices that vary by time of day. Exposes the set of locations and, + * for each of them, the time intervals during which travel may occur. The enricher uses this map to decide which + * timeframes (buckets of time) to fetch from the maps service. + */ +public interface LocationsAndTrafficAwareSolverModel> + extends LocationsAwareSolverModel { + + /** + * @return the locations relevant to the plan, mapped to the time intervals during which each location may be + * involved in travel. Each {@link TimeInterval} covers a continuous range {@code [from, to]}; the enricher + * determines which timeframe buckets the interval overlaps and fetches only the matrices needed for those + * buckets. A location relevant to only one timeframe needs a single narrow interval; a location active + * across the whole day would have a wider interval (or multiple intervals). Must not be {@code null} or + * empty during enrichment. Every key must also appear in {@link #getLocations()}. + */ + Map> getLocationsWithTimeAvailability(); + + /** + * Location sets are not supported on the traffic-aware path. + */ + @Override + default Optional getLocationSetName() { + return Optional.empty(); + } + +} diff --git a/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/MapServiceOptions.java b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/MapServiceOptions.java index 4153b3c6bd..f472c75076 100644 --- a/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/MapServiceOptions.java +++ b/model/maps/service-integration/src/main/java/ai/timefold/solver/model/maps/service/integration/internal/MapServiceOptions.java @@ -24,6 +24,14 @@ public class MapServiceOptions { public static final String TRANSPORT_TYPE = "transportType"; + public static final String ANNOTATION = "annotation"; + + public static final String ANNOTATION_DURATION = "duration"; + + public static final String ANNOTATION_DISTANCE = "distance"; + + public static final String TIMEFRAME = "timeframe"; + public static Map parse(String options) { Map optionMap = new HashMap<>(); @@ -107,4 +115,26 @@ public static String getTransportTypeOption(String transportType) { return TRANSPORT_TYPE + ":" + transportType; } + public static String getAnnotationOption(String annotation) { + if (annotation == null) { + return ""; + } + return ANNOTATION + ":" + annotation; + } + + public static String getTimeframeOption(String timeframe) { + if (timeframe == null) { + return ""; + } + return TIMEFRAME + ":" + timeframe; + } + + public static String withOption(String options, String key, String value) { + String entry = key + ":" + value; + if (options == null || options.isEmpty()) { + return entry; + } + return options + "," + entry; + } + } diff --git a/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/api/TestDistanceCalculator.java b/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/api/TestDistanceCalculator.java index 58c8028ca1..900ba82e0c 100644 --- a/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/api/TestDistanceCalculator.java +++ b/model/maps/service-test/src/main/java/ai/timefold/solver/model/maps/service/test/api/TestDistanceCalculator.java @@ -1,11 +1,13 @@ package ai.timefold.solver.model.maps.service.test.api; +import java.time.OffsetDateTime; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Set; import java.util.function.BiFunction; +import java.util.function.ToIntFunction; import ai.timefold.solver.model.maps.api.DistanceMatrix; import ai.timefold.solver.model.maps.api.model.Location; @@ -34,6 +36,43 @@ public static void initDistanceMaps(List locations, }); } + /** + * Traffic-aware variant of {@link #initDistanceMaps}: builds one travel-time matrix and one distance matrix per + * timeframe and wires them onto each location together with a resolver that maps an {@link OffsetDateTime} to the + * matching array index. The caller defines the bucketing — for the production three-bucket morning/afternoon/night + * split, pass {@code new StaticDaypartBucketing()::indexOf} as {@code indexResolver} and three providers in the same + * order as that bucketing's {@code allTimeframes()}. + * + * @param distanceProviders one {@code (from, to) -> distance} per timeframe; size and order define the timeframe + * array layout (matched against {@code indexResolver}). Must be the same size as {@code travelTimeProviders}. + * @param travelTimeProviders one {@code (from, to) -> travel time} per timeframe; same size and order as + * {@code distanceProviders}. + * @param indexResolver maps a query instant to the timeframe array index used by the matrices above. + */ + public static void initDistanceMaps(List locations, + List> distanceProviders, + List> travelTimeProviders, + ToIntFunction indexResolver) { + if (distanceProviders.size() != travelTimeProviders.size()) { + throw new IllegalArgumentException(("distanceProviders (size %d) and travelTimeProviders (size %d) must " + + "have the same size; one entry per timeframe.") + .formatted(distanceProviders.size(), travelTimeProviders.size())); + } + int timeframeCount = distanceProviders.size(); + DistanceMatrix[] travelTimesByTimeframe = new DistanceMatrix[timeframeCount]; + DistanceMatrix[] distancesByTimeframe = new DistanceMatrix[timeframeCount]; + for (int idx = 0; idx < timeframeCount; idx++) { + TravelTimeAndDistance matrices = calculateBulkDistance(locations, locations, + distanceProviders.get(idx), travelTimeProviders.get(idx)); + travelTimesByTimeframe[idx] = matrices.travelTime(); + distancesByTimeframe[idx] = matrices.distance(); + } + locations.forEach(location -> { + location.setTravelTimeMatrices(travelTimesByTimeframe, indexResolver); + location.setDistanceMatrices(distancesByTimeframe, indexResolver); + }); + } + private static TravelTimeAndDistance calculateBulkDistance( Collection fromLocations, Collection toLocations, diff --git a/model/quarkus/deployment/src/main/java/ai/timefold/solver/model/quarkus/deployment/DefaultConfigProfileProcessor.java b/model/quarkus/deployment/src/main/java/ai/timefold/solver/model/quarkus/deployment/DefaultConfigProfileProcessor.java index c5a7d275ea..b930e4aca2 100644 --- a/model/quarkus/deployment/src/main/java/ai/timefold/solver/model/quarkus/deployment/DefaultConfigProfileProcessor.java +++ b/model/quarkus/deployment/src/main/java/ai/timefold/solver/model/quarkus/deployment/DefaultConfigProfileProcessor.java @@ -47,6 +47,7 @@ public class DefaultConfigProfileProcessor { private static final String MODEL_CONFIG_MAP_PROVIDER = "ai.timefold.model.default-config.map.provider"; private static final String MODEL_CONFIG_MAP_DISTANCE = "ai.timefold.model.default-config.map.max-distance-from-road"; private static final String MODEL_CONFIG_MAP_TRANSPORT_TYPE = "ai.timefold.model.default-config.map.transport-type"; + private static final String MODEL_CONFIG_MAP_USE_TRAFFIC = "ai.timefold.model.default-config.map.use-traffic"; public static final String MODEL_CONFIG_TERMINATION_SPENT_LIMIT = "ai.timefold.model.default-config.termination.spent-limit"; @@ -92,6 +93,7 @@ public void generateDefaultConfigProfile(ModelInfoBuildItem modelInfo, String location = config.getOptionalValue(MODEL_CONFIG_MAP_LOCATION, String.class).orElse(null); Double maxDistanceFromRoad = config.getOptionalValue(MODEL_CONFIG_MAP_DISTANCE, Double.class).orElse(null); String transportType = config.getOptionalValue(MODEL_CONFIG_MAP_TRANSPORT_TYPE, String.class).orElse(null); + Boolean useTraffic = config.getOptionalValue(MODEL_CONFIG_MAP_USE_TRAFFIC, Boolean.class).orElse(null); Integer maxThreadCount = config.getOptionalValue(MODEL_CONFIG_MAX_THREAD_COUNT, Integer.class).orElse(null); Duration spentLimit = config.getOptionalValue(MODEL_CONFIG_TERMINATION_SPENT_LIMIT, Duration.class).orElse(null); Duration unimprovedSpentLimit = @@ -106,7 +108,7 @@ public void generateDefaultConfigProfile(ModelInfoBuildItem modelInfo, ConfigurationProfile configProfile = new ConfigurationProfile(DEFAULT_CONFIGURATION_PROFILE_ID, configName, description, - new MapsConfiguration(provider, location, maxDistanceFromRoad, transportType), + new MapsConfiguration(provider, location, maxDistanceFromRoad, transportType, useTraffic), new ResourcesConfiguration(null, null), new RunConfiguration(maxThreadCount, new SolverTerminationConfig(spentLimit, unimprovedSpentLimit)),