From 21b46e0caddcbede53f742e86aa6c09cffaf1da8 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Mon, 10 Aug 2026 17:50:32 +0200 Subject: [PATCH 01/10] Draft: transport-type-aware Location travel-time/distance lookups Add a TransportType value type and per-mode travel-time/distance matrices on Location, so the same routing problem can use more than one transport profile (e.g. car + bike). The default CAR mode keeps the existing scalar/ timeframe fields and index-cache fast path; extra modes are stored in opt-in maps that stay null for single-mode problems. The TravelTimeMatrixEnricher fetches one matrix set per transport type (each resolving to its own OSRM instance), and map-service.transport-type now accepts a comma-separated list of modes (first entry is primary). Co-Authored-By: Claude Opus 4.8 --- .../service/maps/api/model/Location.java | 196 ++++++++++++++++++ .../service/maps/api/model/TransportType.java | 25 +++ .../client/api/TravelTimeMatrixEnricher.java | 52 +++-- .../impl/MapServiceOptionsSupplier.java | 38 +++- 4 files changed, 289 insertions(+), 22 deletions(-) create mode 100644 service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java index 6fe634349f7..e8a70120c49 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java @@ -1,6 +1,8 @@ package ai.timefold.solver.service.maps.api.model; import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; import java.util.function.ToIntFunction; import jakarta.validation.constraints.Max; @@ -50,6 +52,23 @@ public class Location { @JsonIgnore private ToIntFunction timeframeIndexResolver; + // Non-default transport types only. The default mode ({@link TransportType#CAR}) continues to use the scalar/ + // timeframe fields above so its lookups keep the IndexableDistanceMatrix index-cache fast path; additional modes + // are looked up through each matrix's own index map (same path the per-timeframe matrices already use). + // The timeframeIndexResolver above is shared across modes: traffic bucketing depends on the departure time, not + // on the routing profile. + @JsonIgnore + private Map travelTimeMatrixByMode; + + @JsonIgnore + private Map distanceMatrixByMode; + + @JsonIgnore + private Map travelTimesByTimeframeByMode; + + @JsonIgnore + private Map distancesByTimeframeByMode; + public Location() { } @@ -120,6 +139,54 @@ public void setDistanceMatrices(DistanceMatrix[] distancesByTimeframe, } } + public void setTravelTimeMatrix(TransportType transportType, DistanceMatrix travelTimeMatrix) { + if (isDefaultMode(transportType)) { + setTravelTimeMatrix(travelTimeMatrix); + return; + } + if (travelTimeMatrixByMode == null) { + travelTimeMatrixByMode = new HashMap<>(); + } + travelTimeMatrixByMode.put(transportType, travelTimeMatrix); + } + + public void setDistanceMatrix(TransportType transportType, DistanceMatrix distanceMatrix) { + if (isDefaultMode(transportType)) { + setDistanceMatrix(distanceMatrix); + return; + } + if (distanceMatrixByMode == null) { + distanceMatrixByMode = new HashMap<>(); + } + distanceMatrixByMode.put(transportType, distanceMatrix); + } + + public void setTravelTimeMatrices(TransportType transportType, DistanceMatrix[] travelTimesByTimeframe, + ToIntFunction indexResolver) { + if (isDefaultMode(transportType)) { + setTravelTimeMatrices(travelTimesByTimeframe, indexResolver); + return; + } + if (travelTimesByTimeframeByMode == null) { + travelTimesByTimeframeByMode = new HashMap<>(); + } + travelTimesByTimeframeByMode.put(transportType, travelTimesByTimeframe); + this.timeframeIndexResolver = indexResolver; + } + + public void setDistanceMatrices(TransportType transportType, DistanceMatrix[] distancesByTimeframe, + ToIntFunction indexResolver) { + if (isDefaultMode(transportType)) { + setDistanceMatrices(distancesByTimeframe, indexResolver); + return; + } + if (distancesByTimeframeByMode == null) { + distancesByTimeframeByMode = new HashMap<>(); + } + distancesByTimeframeByMode.put(transportType, distancesByTimeframe); + this.timeframeIndexResolver = indexResolver; + } + /** * Returns the travel time for a route between this location and the given location. * @@ -226,6 +293,69 @@ public TravelDistance getDistanceTo(Location location, OffsetDateTime departureT return TravelDistance.of(distance); } + /** + * Returns the travel time for a route between this location and the given location using the given transport type. + * + * @param location the location representing the route destination + * @param transportType the routing profile to use; {@code null} is treated as {@link TransportType#CAR} + * @return {@link TravelTime} instance representing the travel time in seconds. + * @throws IllegalArgumentException When the resolved matrix does not include both locations. + * @throws IllegalStateException When no travel time matrix is configured for the given transport type. + */ + public TravelTime getTravelTimeTo(Location location, TransportType transportType) { + DistanceMatrix matrix = travelTimeMatrixForMode(transportType); + return TravelTime.of(lookup(matrix, location, transportType, "travel time", null)); + } + + /** + * Returns the travel time for a route between this location and the given location at the given departure time, + * using the given transport type. + * + * @param location the location representing the route destination + * @param departureTime the instant used to select the traffic timeframe matrix + * @param transportType the routing profile to use; {@code null} is treated as {@link TransportType#CAR} + * @return {@link TravelTime} instance representing the travel time in seconds. + * @throws IllegalArgumentException When the resolved matrix does not include both locations, or the resolver + * returns an out-of-bounds index. + * @throws IllegalStateException When no travel time matrix is configured for the given transport type. + */ + public TravelTime getTravelTimeTo(Location location, OffsetDateTime departureTime, TransportType transportType) { + DistanceMatrix matrix = travelTimeMatrixForMode(transportType, departureTime); + return TravelTime.of(lookup(matrix, location, transportType, "travel time", departureTime)); + } + + /** + * Returns the travel distance for a route between this location and the given location using the given transport + * type. + * + * @param location the location representing the route destination + * @param transportType the routing profile to use; {@code null} is treated as {@link TransportType#CAR} + * @return {@link TravelDistance} instance representing the travel distance in meters. + * @throws IllegalArgumentException When the resolved matrix does not include both locations. + * @throws IllegalStateException When no distance matrix is configured for the given transport type. + */ + public TravelDistance getDistanceTo(Location location, TransportType transportType) { + DistanceMatrix matrix = distanceMatrixForMode(transportType); + return TravelDistance.of(lookup(matrix, location, transportType, "distance", null)); + } + + /** + * Returns the travel distance for a route between this location and the given location at the given departure + * time, using the given transport type. + * + * @param location the location representing the route destination + * @param departureTime the instant used to select the traffic timeframe matrix + * @param transportType the routing profile to use; {@code null} is treated as {@link TransportType#CAR} + * @return {@link TravelDistance} instance representing the travel distance in meters. + * @throws IllegalArgumentException When the resolved matrix does not include both locations, or the resolver + * returns an out-of-bounds index. + * @throws IllegalStateException When no distance matrix is configured for the given transport type. + */ + public TravelDistance getDistanceTo(Location location, OffsetDateTime departureTime, TransportType transportType) { + DistanceMatrix matrix = distanceMatrixForMode(transportType, departureTime); + return TravelDistance.of(lookup(matrix, location, transportType, "distance", departureTime)); + } + public short getIndex(DistanceMatrix matrix) { if (matrix == travelTimeMatrix) { return travelTimeMatrixIndex; @@ -280,6 +410,72 @@ private DistanceMatrix resolveTimeframeMatrix(DistanceMatrix[] matrices, OffsetD return matrix; } + private static boolean isDefaultMode(TransportType transportType) { + return transportType == null || TransportType.CAR.equals(transportType); + } + + private DistanceMatrix travelTimeMatrixForMode(TransportType transportType) { + if (isDefaultMode(transportType)) { + return travelTimeMatrix; + } + return travelTimeMatrixByMode == null ? null : travelTimeMatrixByMode.get(transportType); + } + + private DistanceMatrix travelTimeMatrixForMode(TransportType transportType, OffsetDateTime departureTime) { + if (isDefaultMode(transportType)) { + return hasTimeframeMatrices(travelTimesByTimeframe) + ? resolveTimeframeMatrix(travelTimesByTimeframe, departureTime, "travel time") + : travelTimeMatrix; + } + DistanceMatrix[] byTimeframe = travelTimesByTimeframeByMode == null + ? null + : travelTimesByTimeframeByMode.get(transportType); + if (byTimeframe != null && timeframeIndexResolver != null) { + return resolveTimeframeMatrix(byTimeframe, departureTime, "travel time"); + } + return travelTimeMatrixByMode == null ? null : travelTimeMatrixByMode.get(transportType); + } + + private DistanceMatrix distanceMatrixForMode(TransportType transportType) { + if (isDefaultMode(transportType)) { + return distanceMatrix; + } + return distanceMatrixByMode == null ? null : distanceMatrixByMode.get(transportType); + } + + private DistanceMatrix distanceMatrixForMode(TransportType transportType, OffsetDateTime departureTime) { + if (isDefaultMode(transportType)) { + return hasTimeframeMatrices(distancesByTimeframe) + ? resolveTimeframeMatrix(distancesByTimeframe, departureTime, "distance") + : distanceMatrix; + } + DistanceMatrix[] byTimeframe = distancesByTimeframeByMode == null + ? null + : distancesByTimeframeByMode.get(transportType); + if (byTimeframe != null && timeframeIndexResolver != null) { + return resolveTimeframeMatrix(byTimeframe, departureTime, "distance"); + } + return distanceMatrixByMode == null ? null : distanceMatrixByMode.get(transportType); + } + + private long lookup(DistanceMatrix matrix, Location to, TransportType transportType, String what, + OffsetDateTime departureTime) { + TransportType resolvedMode = transportType == null ? TransportType.CAR : transportType; + if (matrix == null) { + throw new IllegalStateException( + "No %s matrix configured for a location (%s) and transport type (%s).".formatted(what, this, + resolvedMode)); + } + long value = matrix.get(this, to); + if (value == -1) { + String at = departureTime == null ? "" : " at (%s)".formatted(departureTime); + throw new IllegalArgumentException(("No %s information found for a route from (%s) to (%s) for transport " + + "type (%s)%s. Are both locations in the configured map and in the location set (if used)?") + .formatted(what, this, to, resolvedMode, at)); + } + return value; + } + private void updateIndex(DistanceMatrix distanceMatrix) { if (distanceMatrix instanceof IndexableDistanceMatrix indexableDistanceMatrix) { indexableDistanceMatrix.updateCachedIndex(this); diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java new file mode 100644 index 00000000000..d44c8c6b8bd --- /dev/null +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java @@ -0,0 +1,25 @@ +package ai.timefold.solver.service.maps.api.model; + +import java.util.Objects; + +public record TransportType(String value) { + + public static final TransportType CAR = new TransportType("car"); + + public TransportType { + Objects.requireNonNull(value, "TransportType value must not be null."); + value = value.trim().toLowerCase(); + if (value.isEmpty()) { + throw new IllegalArgumentException("TransportType value must not be blank."); + } + } + + public static TransportType of(String value) { + return new TransportType(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index 5ef4ebf85eb..c2e7a2c75e6 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -13,6 +13,7 @@ import ai.timefold.solver.service.definition.internal.error.TimefoldRuntimeException; import ai.timefold.solver.service.maps.api.DistanceMatrix; import ai.timefold.solver.service.maps.api.model.Location; +import ai.timefold.solver.service.maps.api.model.TransportType; import ai.timefold.solver.service.maps.service.client.api.model.TravelTimesByTimeframeWithMetadata; import ai.timefold.solver.service.maps.service.client.impl.MapServiceOptionsSupplier; import ai.timefold.solver.service.maps.service.client.impl.error.MapServiceIllegalArgumentException; @@ -55,19 +56,30 @@ public TravelTimeMatrixEnricher(MapService mapService, MapServiceOptionsSupplier }) @Override public LocationsAwareSolverModel enrich(LocationsAwareSolverModel solverModel) { - if (useTraffic) { - return enrichAllTimeframes(solverModel); + // One map-service round-trip per transport type; each mode resolves to its own OSRM instance. The first + // mode is treated as primary and is the one whose map metadata (locations-not-in-map, resolved location) is + // propagated to the solver model. + List transportTypes = optionsSupplier.getTransportTypes(); + for (int i = 0; i < transportTypes.size(); i++) { + TransportType transportType = transportTypes.get(i); + boolean primary = i == 0; + if (useTraffic) { + enrichAllTimeframes(solverModel, transportType, primary); + } else { + enrichSingleMatrix(solverModel, transportType, primary); + } } - return enrichSingleMatrix(solverModel); + return solverModel; } - private LocationsAwareSolverModel enrichSingleMatrix(LocationsAwareSolverModel solverModel) { + private void enrichSingleMatrix(LocationsAwareSolverModel solverModel, TransportType transportType, + boolean primary) { List locations = solverModel.getLocations(); // Get all the locations from the model only once. TravelTimeAndDistanceWithMetadata travelTimeAndDistance; try { travelTimeAndDistance = mapService.getTravelTimeAndDistance(locations, - optionsSupplier.getOptions(solverModel.getLocationSetName())); + optionsSupplier.getOptions(solverModel.getLocationSetName(), transportType)); } catch (TimefoldRuntimeException e) { throw e; } catch (Exception e) { @@ -76,19 +88,22 @@ private LocationsAwareSolverModel enrichSingleMatrix(LocationsAwareSolverMode "Error getting travel time and distances from map service", e, false); } locations.forEach(location -> { - location.setTravelTimeMatrix(travelTimeAndDistance.travelTimeAndDistance().travelTime()); - location.setDistanceMatrix(travelTimeAndDistance.travelTimeAndDistance().distance()); + location.setTravelTimeMatrix(transportType, travelTimeAndDistance.travelTimeAndDistance().travelTime()); + location.setDistanceMatrix(transportType, travelTimeAndDistance.travelTimeAndDistance().distance()); }); - solverModel.setLocationsNotInMap(convertIdxToLocations(travelTimeAndDistance.locationsNotInMapIdx(), locations)); - mapEnrichmentContext.setResolvedMapLocation(travelTimeAndDistance.resolvedMapLocation()); - return solverModel; + if (primary) { + solverModel + .setLocationsNotInMap(convertIdxToLocations(travelTimeAndDistance.locationsNotInMapIdx(), locations)); + mapEnrichmentContext.setResolvedMapLocation(travelTimeAndDistance.resolvedMapLocation()); + } } - private LocationsAwareSolverModel enrichAllTimeframes(LocationsAwareSolverModel solverModel) { + private void enrichAllTimeframes(LocationsAwareSolverModel solverModel, TransportType transportType, + boolean primary) { List locations = solverModel.getLocations(); TravelTimesByTimeframeWithMetadata result; try { - result = mapService.getTravelTimeAndDistanceByTimeframe(locations, optionsSupplier.getOptions()); + result = mapService.getTravelTimeAndDistanceByTimeframe(locations, optionsSupplier.getOptions(transportType)); } catch (TimefoldRuntimeException e) { throw e; } catch (Exception e) { @@ -103,17 +118,18 @@ private LocationsAwareSolverModel enrichAllTimeframes(LocationsAwareSolverMod // IndexableDistanceMatrix index-cache fast path. The time-aware overloads keep working because Location // falls back to the single matrix when no per-timeframe matrices are set. for (Location location : locations) { - location.setTravelTimeMatrix(travelTimes[0]); - location.setDistanceMatrix(distances[0]); + location.setTravelTimeMatrix(transportType, travelTimes[0]); + location.setDistanceMatrix(transportType, distances[0]); } } else { for (Location location : locations) { - location.setTravelTimeMatrices(travelTimes, result.timeframeIndexResolver()); - location.setDistanceMatrices(distances, result.timeframeIndexResolver()); + location.setTravelTimeMatrices(transportType, travelTimes, result.timeframeIndexResolver()); + location.setDistanceMatrices(transportType, distances, result.timeframeIndexResolver()); } } - solverModel.setLocationsNotInMap(result.locationsNotInMap()); - return solverModel; + if (primary) { + solverModel.setLocationsNotInMap(result.locationsNotInMap()); + } } @Override diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java index 977fb136271..fd021288e0d 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java @@ -1,11 +1,14 @@ package ai.timefold.solver.service.maps.service.client.impl; +import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import jakarta.enterprise.context.ApplicationScoped; +import ai.timefold.solver.service.maps.api.model.TransportType; import ai.timefold.solver.service.maps.service.integration.internal.MapServiceOptions; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -25,10 +28,10 @@ public class MapServiceOptionsSupplier { private final Optional tenantId; - private final Optional transportType; - private final Optional maxDistanceFromRoad; + private final List transportTypes; + public MapServiceOptionsSupplier( @ConfigProperty(name = "timefold.platform.map-service.provider") Optional provider, @ConfigProperty(name = "timefold.platform.map-service.location") Optional location, @@ -46,7 +49,11 @@ public MapServiceOptionsSupplier( this.modelResource = modelResource; this.tenantId = tenantId; this.maxDistanceFromRoad = maxDistanceFromRoad; - this.transportType = transportType; + this.transportTypes = resolveTransportTypes(transportType); + } + + public List getTransportTypes() { + return transportTypes; } public String getOptions() { @@ -54,6 +61,15 @@ public String getOptions() { } public String getOptions(Optional locationSetName) { + // Legacy single-mode callers get the primary transport type. + return getOptions(locationSetName, transportTypes.get(0)); + } + + public String getOptions(TransportType transportType) { + return getOptions(Optional.empty(), transportType); + } + + public String getOptions(Optional locationSetName, TransportType transportType) { String providerOption = provider.map(MapServiceOptions::getProviderOption).orElse(""); String locationOption = location.map(MapServiceOptions::getLocationOption).orElse(""); String modelOption = model.map(MapServiceOptions::getModelOption).orElse(""); @@ -62,7 +78,9 @@ public String getOptions(Optional locationSetName) { String tenantIdOption = tenantId.map(MapServiceOptions::getTenantIdOption).orElse(""); String locationSetNameOption = locationSetName.map(MapServiceOptions::getLocationSetNameOption).orElse(""); String maxDistanceFromRoadOption = maxDistanceFromRoad.map(MapServiceOptions::getMaxDistanceFromRoadOption).orElse(""); - String transportTypeOption = transportType.map(MapServiceOptions::getTransportTypeOption).orElse(""); + String transportTypeOption = transportType == null + ? "" + : MapServiceOptions.getTransportTypeOption(transportType.value()); String options = Stream .of(providerOption, locationOption, modelOption, modelVersionOption, modelResourceOption, tenantIdOption, locationSetNameOption, @@ -72,4 +90,16 @@ public String getOptions(Optional locationSetName) { return options.isEmpty() ? "" : options; } + private static List resolveTransportTypes(Optional transportType) { + return transportType + .map(value -> Arrays.stream(value.split(",")) + .map(String::trim) + .filter(part -> !part.isEmpty()) + .map(TransportType::of) + .distinct() + .toList()) + .filter(list -> !list.isEmpty()) + .orElseGet(() -> List.of(TransportType.CAR)); + } + } From 862e09d8f7eef49ec190ceeb1a83780f4816a53f Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Mon, 10 Aug 2026 17:51:10 +0200 Subject: [PATCH 02/10] feat: remove comments --- .../ai/timefold/solver/service/maps/api/model/Location.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java index e8a70120c49..7d0915deaab 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java @@ -53,10 +53,7 @@ public class Location { private ToIntFunction timeframeIndexResolver; // Non-default transport types only. The default mode ({@link TransportType#CAR}) continues to use the scalar/ - // timeframe fields above so its lookups keep the IndexableDistanceMatrix index-cache fast path; additional modes - // are looked up through each matrix's own index map (same path the per-timeframe matrices already use). - // The timeframeIndexResolver above is shared across modes: traffic bucketing depends on the departure time, not - // on the routing profile. + // timeframe fields above so its lookups keep the IndexableDistanceMatrix index-cache fast path. @JsonIgnore private Map travelTimeMatrixByMode; From 9173ac66482fd2b92a18699ba6f26fbec984cf8d Mon Sep 17 00:00:00 2001 From: Marek Winkler Date: Fri, 4 Sep 2026 11:31:52 +0200 Subject: [PATCH 03/10] chore: change TransportType to enum to quick fix OpenAPI spec --- .../service/maps/api/model/TransportType.java | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java index d44c8c6b8bd..073250935ac 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java @@ -2,20 +2,36 @@ import java.util.Objects; -public record TransportType(String value) { +import org.eclipse.microprofile.openapi.annotations.media.Schema; - public static final TransportType CAR = new TransportType("car"); +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; - public TransportType { +@Schema(description = "The type of transport used (car, bike, ... ) supported by Timefold.") +public enum TransportType { + + CAR("car"); + + private final String value; + + TransportType(String value) { Objects.requireNonNull(value, "TransportType value must not be null."); value = value.trim().toLowerCase(); if (value.isEmpty()) { throw new IllegalArgumentException("TransportType value must not be blank."); } + this.value = value; } + @JsonCreator public static TransportType of(String value) { - return new TransportType(value); + Objects.requireNonNull(value, "TransportType value must not be null."); + return TransportType.valueOf(value.trim().toUpperCase()); + } + + @JsonValue + public String value() { + return value; } @Override From 0f53d1ac37ce4f1659b871008ca23f6746322b9d Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Thu, 10 Sep 2026 17:08:03 +0200 Subject: [PATCH 04/10] feat: add Transport types and replace map of distance matrixes by array --- .../service/maps/api/model/Location.java | 48 ++++++++++--------- .../service/maps/api/model/TransportType.java | 4 +- .../client/api/TravelTimeMatrixEnricher.java | 4 +- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java index 7d0915deaab..334c74213dd 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/Location.java @@ -1,8 +1,6 @@ package ai.timefold.solver.service.maps.api.model; import java.time.OffsetDateTime; -import java.util.HashMap; -import java.util.Map; import java.util.function.ToIntFunction; import jakarta.validation.constraints.Max; @@ -52,19 +50,23 @@ public class Location { @JsonIgnore private ToIntFunction timeframeIndexResolver; + // Number of transport modes; used to size the per-mode arrays below. + private static final int MODE_COUNT = TransportType.values().length; + // Non-default transport types only. The default mode ({@link TransportType#CAR}) continues to use the scalar/ // timeframe fields above so its lookups keep the IndexableDistanceMatrix index-cache fast path. + // Indexed by {@link TransportType#ordinal()} for fast hot-path lookups; allocated lazily on first non-default set. @JsonIgnore - private Map travelTimeMatrixByMode; + private DistanceMatrix[] travelTimeMatrixByMode; @JsonIgnore - private Map distanceMatrixByMode; + private DistanceMatrix[] distanceMatrixByMode; @JsonIgnore - private Map travelTimesByTimeframeByMode; + private DistanceMatrix[][] travelTimesByTimeframeByMode; @JsonIgnore - private Map distancesByTimeframeByMode; + private DistanceMatrix[][] distancesByTimeframeByMode; public Location() { } @@ -142,9 +144,9 @@ public void setTravelTimeMatrix(TransportType transportType, DistanceMatrix trav return; } if (travelTimeMatrixByMode == null) { - travelTimeMatrixByMode = new HashMap<>(); + travelTimeMatrixByMode = new DistanceMatrix[MODE_COUNT]; } - travelTimeMatrixByMode.put(transportType, travelTimeMatrix); + travelTimeMatrixByMode[transportType.ordinal()] = travelTimeMatrix; } public void setDistanceMatrix(TransportType transportType, DistanceMatrix distanceMatrix) { @@ -153,9 +155,9 @@ public void setDistanceMatrix(TransportType transportType, DistanceMatrix distan return; } if (distanceMatrixByMode == null) { - distanceMatrixByMode = new HashMap<>(); + distanceMatrixByMode = new DistanceMatrix[MODE_COUNT]; } - distanceMatrixByMode.put(transportType, distanceMatrix); + distanceMatrixByMode[transportType.ordinal()] = distanceMatrix; } public void setTravelTimeMatrices(TransportType transportType, DistanceMatrix[] travelTimesByTimeframe, @@ -165,9 +167,9 @@ public void setTravelTimeMatrices(TransportType transportType, DistanceMatrix[] return; } if (travelTimesByTimeframeByMode == null) { - travelTimesByTimeframeByMode = new HashMap<>(); + travelTimesByTimeframeByMode = new DistanceMatrix[MODE_COUNT][]; } - travelTimesByTimeframeByMode.put(transportType, travelTimesByTimeframe); + travelTimesByTimeframeByMode[transportType.ordinal()] = travelTimesByTimeframe; this.timeframeIndexResolver = indexResolver; } @@ -178,9 +180,9 @@ public void setDistanceMatrices(TransportType transportType, DistanceMatrix[] di return; } if (distancesByTimeframeByMode == null) { - distancesByTimeframeByMode = new HashMap<>(); + distancesByTimeframeByMode = new DistanceMatrix[MODE_COUNT][]; } - distancesByTimeframeByMode.put(transportType, distancesByTimeframe); + distancesByTimeframeByMode[transportType.ordinal()] = distancesByTimeframe; this.timeframeIndexResolver = indexResolver; } @@ -332,7 +334,7 @@ public TravelTime getTravelTimeTo(Location location, OffsetDateTime departureTim * @throws IllegalStateException When no distance matrix is configured for the given transport type. */ public TravelDistance getDistanceTo(Location location, TransportType transportType) { - DistanceMatrix matrix = distanceMatrixForMode(transportType); + var matrix = distanceMatrixForMode(transportType); return TravelDistance.of(lookup(matrix, location, transportType, "distance", null)); } @@ -349,7 +351,7 @@ public TravelDistance getDistanceTo(Location location, TransportType transportTy * @throws IllegalStateException When no distance matrix is configured for the given transport type. */ public TravelDistance getDistanceTo(Location location, OffsetDateTime departureTime, TransportType transportType) { - DistanceMatrix matrix = distanceMatrixForMode(transportType, departureTime); + var matrix = distanceMatrixForMode(transportType, departureTime); return TravelDistance.of(lookup(matrix, location, transportType, "distance", departureTime)); } @@ -408,14 +410,14 @@ private DistanceMatrix resolveTimeframeMatrix(DistanceMatrix[] matrices, OffsetD } private static boolean isDefaultMode(TransportType transportType) { - return transportType == null || TransportType.CAR.equals(transportType); + return transportType == null || TransportType.CAR == transportType; } private DistanceMatrix travelTimeMatrixForMode(TransportType transportType) { if (isDefaultMode(transportType)) { return travelTimeMatrix; } - return travelTimeMatrixByMode == null ? null : travelTimeMatrixByMode.get(transportType); + return travelTimeMatrixByMode == null ? null : travelTimeMatrixByMode[transportType.ordinal()]; } private DistanceMatrix travelTimeMatrixForMode(TransportType transportType, OffsetDateTime departureTime) { @@ -426,18 +428,18 @@ private DistanceMatrix travelTimeMatrixForMode(TransportType transportType, Offs } DistanceMatrix[] byTimeframe = travelTimesByTimeframeByMode == null ? null - : travelTimesByTimeframeByMode.get(transportType); + : travelTimesByTimeframeByMode[transportType.ordinal()]; if (byTimeframe != null && timeframeIndexResolver != null) { return resolveTimeframeMatrix(byTimeframe, departureTime, "travel time"); } - return travelTimeMatrixByMode == null ? null : travelTimeMatrixByMode.get(transportType); + return travelTimeMatrixByMode == null ? null : travelTimeMatrixByMode[transportType.ordinal()]; } private DistanceMatrix distanceMatrixForMode(TransportType transportType) { if (isDefaultMode(transportType)) { return distanceMatrix; } - return distanceMatrixByMode == null ? null : distanceMatrixByMode.get(transportType); + return distanceMatrixByMode == null ? null : distanceMatrixByMode[transportType.ordinal()]; } private DistanceMatrix distanceMatrixForMode(TransportType transportType, OffsetDateTime departureTime) { @@ -448,11 +450,11 @@ private DistanceMatrix distanceMatrixForMode(TransportType transportType, Offset } DistanceMatrix[] byTimeframe = distancesByTimeframeByMode == null ? null - : distancesByTimeframeByMode.get(transportType); + : distancesByTimeframeByMode[transportType.ordinal()]; if (byTimeframe != null && timeframeIndexResolver != null) { return resolveTimeframeMatrix(byTimeframe, departureTime, "distance"); } - return distanceMatrixByMode == null ? null : distanceMatrixByMode.get(transportType); + return distanceMatrixByMode == null ? null : distanceMatrixByMode[transportType.ordinal()]; } private long lookup(DistanceMatrix matrix, Location to, TransportType transportType, String what, diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java index 073250935ac..fc394404c92 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java @@ -10,7 +10,9 @@ @Schema(description = "The type of transport used (car, bike, ... ) supported by Timefold.") public enum TransportType { - CAR("car"); + CAR("car"), + BICYCLE("bike"), + FOOT("foot"); private final String value; diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index c2e7a2c75e6..86fed4d3da6 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -60,8 +60,8 @@ public LocationsAwareSolverModel enrich(LocationsAwareSolverModel solverMo // mode is treated as primary and is the one whose map metadata (locations-not-in-map, resolved location) is // propagated to the solver model. List transportTypes = optionsSupplier.getTransportTypes(); - for (int i = 0; i < transportTypes.size(); i++) { - TransportType transportType = transportTypes.get(i); + for (var i = 0; i < transportTypes.size(); i++) { + var transportType = transportTypes.get(i); boolean primary = i == 0; if (useTraffic) { enrichAllTimeframes(solverModel, transportType, primary); From 829157e5946c774efe4dfaadbf69d0c79c96ddce Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Thu, 10 Sep 2026 17:28:47 +0200 Subject: [PATCH 05/10] feat: remove options from argument in MapsServiceOptionsSupplier --- .../service/client/api/TravelTimeMatrixEnricher.java | 2 +- .../client/impl/MapServiceOptionsSupplier.java | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index 86fed4d3da6..ccef21c7f8f 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -79,7 +79,7 @@ private void enrichSingleMatrix(LocationsAwareSolverModel solverModel, Transp try { travelTimeAndDistance = mapService.getTravelTimeAndDistance(locations, - optionsSupplier.getOptions(solverModel.getLocationSetName(), transportType)); + optionsSupplier.getOptions(solverModel.getLocationSetName().orElse(null), transportType)); } catch (TimefoldRuntimeException e) { throw e; } catch (Exception e) { diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java index fd021288e0d..c1d67aefb7d 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java @@ -57,26 +57,27 @@ public List getTransportTypes() { } public String getOptions() { - return getOptions(Optional.empty()); + return getOptions((String) null); } - public String getOptions(Optional locationSetName) { + public String getOptions(String locationSetName) { // Legacy single-mode callers get the primary transport type. return getOptions(locationSetName, transportTypes.get(0)); } public String getOptions(TransportType transportType) { - return getOptions(Optional.empty(), transportType); + return getOptions((String) null, transportType); } - public String getOptions(Optional locationSetName, TransportType transportType) { + public String getOptions(String locationSetName, TransportType transportType) { String providerOption = provider.map(MapServiceOptions::getProviderOption).orElse(""); String locationOption = location.map(MapServiceOptions::getLocationOption).orElse(""); String modelOption = model.map(MapServiceOptions::getModelOption).orElse(""); String modelVersionOption = modelVersion.map(MapServiceOptions::getModelVersionOption).orElse(""); String modelResourceOption = modelResource.map(MapServiceOptions::getModelResourceOption).orElse(""); String tenantIdOption = tenantId.map(MapServiceOptions::getTenantIdOption).orElse(""); - String locationSetNameOption = locationSetName.map(MapServiceOptions::getLocationSetNameOption).orElse(""); + String locationSetNameOption = + locationSetName == null ? "" : MapServiceOptions.getLocationSetNameOption(locationSetName); String maxDistanceFromRoadOption = maxDistanceFromRoad.map(MapServiceOptions::getMaxDistanceFromRoadOption).orElse(""); String transportTypeOption = transportType == null ? "" From 2a66083086bd6f3288322a9e5fef25bc03ba56b6 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Wed, 16 Sep 2026 14:23:09 +0200 Subject: [PATCH 06/10] Update transport types --- .../timefold/solver/service/maps/api/model/TransportType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java index fc394404c92..be77e7e3719 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java @@ -7,11 +7,11 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; -@Schema(description = "The type of transport used (car, bike, ... ) supported by Timefold.") +@Schema(description = "The type of transport used (car, bicycle, ... ).") public enum TransportType { CAR("car"), - BICYCLE("bike"), + BICYCLE("bicycle"), FOOT("foot"); private final String value; From a5f4a27c9bd4b3609269a9ffe20b947b95d9f5fb Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Mon, 21 Sep 2026 17:22:07 +0200 Subject: [PATCH 07/10] feat: add allowed transport type to maps service client --- .../definition/internal/error/ErrorCodes.java | 1 + .../service/maps/api/model/TransportType.java | 16 +++- .../client/api/TravelTimeMatrixEnricher.java | 54 +++++++++++- .../impl/MapServiceOptionsSupplier.java | 61 ++++++++++--- .../TravelTimeMatrixEnricherTrafficTest.java | 88 ++++++++++++++++++- .../api/LocationsAwareSolverModel.java | 5 ++ 6 files changed, 210 insertions(+), 15 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/error/ErrorCodes.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/error/ErrorCodes.java index 13a0ac41851..de866e7a0af 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/error/ErrorCodes.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/error/ErrorCodes.java @@ -27,6 +27,7 @@ public class ErrorCodes { public static final String MAP_SERVICE_CLIENT_CONVERT_DISTANCE_RESPONSE_ERROR = PREFIX + "60014"; public static final String MAP_SERVICE_CLIENT_REQUEST_ERROR = PREFIX + "60015"; public static final String MAP_SERVICE_LOCATION_NOT_IN_MAP_ERROR = PREFIX + "60017"; + public static final String MAP_SERVICE_TRANSPORT_TYPE_NOT_ALLOWED = PREFIX + "60018"; public static final String MAP_SERVICE_UNKNOWN = PREFIX + "60099"; // Solver related error codes - starts with 7... diff --git a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java index be77e7e3719..61f149a8506 100644 --- a/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java +++ b/service/maps/api/src/main/java/ai/timefold/solver/service/maps/api/model/TransportType.java @@ -1,5 +1,6 @@ package ai.timefold.solver.service.maps.api.model; +import java.util.List; import java.util.Objects; import org.eclipse.microprofile.openapi.annotations.media.Schema; @@ -12,7 +13,13 @@ public enum TransportType { CAR("car"), BICYCLE("bicycle"), - FOOT("foot"); + FOOT("foot"), + AUTO_SELECT("auto-select"); + + /** + * Every transport type that maps to an actual routing profile, i.e. all but {@link #AUTO_SELECT}. + */ + public static final List ROUTING_PROFILES = List.of(CAR, BICYCLE, FOOT); private final String value; @@ -28,7 +35,8 @@ public enum TransportType { @JsonCreator public static TransportType of(String value) { Objects.requireNonNull(value, "TransportType value must not be null."); - return TransportType.valueOf(value.trim().toUpperCase()); + // Both the wire value ("auto-select") and the enum name ("AUTO_SELECT") are accepted. + return TransportType.valueOf(value.trim().toUpperCase().replace('-', '_')); } @JsonValue @@ -36,6 +44,10 @@ public String value() { return value; } + public boolean isAutoSelect() { + return this == AUTO_SELECT; + } + @Override public String toString() { return value; diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index ccef21c7f8f..ca5bd9683f0 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -3,6 +3,8 @@ import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -59,7 +61,7 @@ public LocationsAwareSolverModel enrich(LocationsAwareSolverModel solverMo // One map-service round-trip per transport type; each mode resolves to its own OSRM instance. The first // mode is treated as primary and is the one whose map metadata (locations-not-in-map, resolved location) is // propagated to the solver model. - List transportTypes = optionsSupplier.getTransportTypes(); + List transportTypes = resolveTransportTypes(solverModel); for (var i = 0; i < transportTypes.size(); i++) { var transportType = transportTypes.get(i); boolean primary = i == 0; @@ -149,4 +151,54 @@ private List convertIdxToLocations(List idx, List l return locationList; } + + /** + * Decides which transport types travel times are fetched for, and rejects the dataset when it uses a transport + * type this deployment is not allowed to route with. + */ + private List resolveTransportTypes(LocationsAwareSolverModel solverModel) { + List datasetTransportTypes = solverModel.getTransportTypes().stream() + .filter(Objects::nonNull) + .distinct() + .sorted() // Deterministic order; CAR, when used, comes first and is therefore the primary one. + .toList(); + if (!optionsSupplier.isAutoSelectTransportType()) { + TransportType configuredTransportType = optionsSupplier.getTransportType(); + failIfNotAllowed(List.of(configuredTransportType)); + List unsupported = datasetTransportTypes.stream() + .filter(transportType -> transportType != configuredTransportType) + .toList(); + if (!unsupported.isEmpty()) { + throw new MapServiceIllegalArgumentException(ErrorCodes.MAP_SERVICE_TRANSPORT_TYPE_NOT_ALLOWED, + ("The dataset uses transport type(s) (%s) other than the configured transport type (%s). " + + "Configure the transport type (%s) to route a single dataset with several transport types.") + .formatted(join(unsupported), configuredTransportType, TransportType.AUTO_SELECT), + false); + } + return List.of(configuredTransportType); + } + if (datasetTransportTypes.isEmpty()) { + return List.of(optionsSupplier.getDefaultTransportType()); + } + failIfNotAllowed(datasetTransportTypes); + return datasetTransportTypes; + } + + private void failIfNotAllowed(List transportTypes) { + List notAllowed = transportTypes.stream() + .filter(transportType -> !optionsSupplier.isAllowed(transportType)) + .toList(); + if (notAllowed.isEmpty()) { + return; + } + List allowed = optionsSupplier.getAllowedTransportTypes(); + throw new MapServiceIllegalArgumentException(ErrorCodes.MAP_SERVICE_TRANSPORT_TYPE_NOT_ALLOWED, + "The transport type(s) (%s) are not allowed; the allowed transport types are (%s)." + .formatted(join(notAllowed), join(allowed.isEmpty() ? TransportType.ROUTING_PROFILES : allowed)), + false); + } + + private static String join(List transportTypes) { + return transportTypes.stream().map(TransportType::value).collect(Collectors.joining(", ")); + } } diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java index c1d67aefb7d..dffaff01c34 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java @@ -30,7 +30,9 @@ public class MapServiceOptionsSupplier { private final Optional maxDistanceFromRoad; - private final List transportTypes; + private final TransportType transportType; + + private final List allowedTransportTypes; public MapServiceOptionsSupplier( @ConfigProperty(name = "timefold.platform.map-service.provider") Optional provider, @@ -41,7 +43,9 @@ public MapServiceOptionsSupplier( @ConfigProperty(name = "timefold.model.api-version") Optional modelVersion, @ConfigProperty(name = "timefold.model.rest-resource") Optional modelResource, @ConfigProperty(name = "timefold.platform.tenant-id") Optional tenantId, - @ConfigProperty(name = "timefold.platform.map-service.transport-type") Optional transportType) { + @ConfigProperty(name = "timefold.platform.map-service.transport-type") Optional transportType, + @ConfigProperty( + name = "timefold.platform.map-service.allowed-transport-types") Optional allowedTransportTypes) { this.provider = provider; this.location = location; this.model = model; @@ -49,11 +53,34 @@ public MapServiceOptionsSupplier( this.modelResource = modelResource; this.tenantId = tenantId; this.maxDistanceFromRoad = maxDistanceFromRoad; - this.transportTypes = resolveTransportTypes(transportType); + this.transportType = resolveTransportType(transportType); + this.allowedTransportTypes = resolveAllowedTransportTypes(allowedTransportTypes); + } + + public TransportType getTransportType() { + return transportType; + } + + public boolean isAutoSelectTransportType() { + return transportType.isAutoSelect(); } - public List getTransportTypes() { - return transportTypes; + public List getAllowedTransportTypes() { + return allowedTransportTypes; + } + + public boolean isAllowed(TransportType transportType) { + if (transportType == null || transportType.isAutoSelect()) { + return false; + } + return allowedTransportTypes.isEmpty() || allowedTransportTypes.contains(transportType); + } + + public TransportType getDefaultTransportType() { + if (isAllowed(TransportType.CAR)) { + return TransportType.CAR; + } + return allowedTransportTypes.getFirst(); } public String getOptions() { @@ -61,8 +88,7 @@ public String getOptions() { } public String getOptions(String locationSetName) { - // Legacy single-mode callers get the primary transport type. - return getOptions(locationSetName, transportTypes.get(0)); + return getOptions(locationSetName, isAutoSelectTransportType() ? getDefaultTransportType() : transportType); } public String getOptions(TransportType transportType) { @@ -79,7 +105,7 @@ public String getOptions(String locationSetName, TransportType transportType) { String locationSetNameOption = locationSetName == null ? "" : MapServiceOptions.getLocationSetNameOption(locationSetName); String maxDistanceFromRoadOption = maxDistanceFromRoad.map(MapServiceOptions::getMaxDistanceFromRoadOption).orElse(""); - String transportTypeOption = transportType == null + String transportTypeOption = transportType == null || transportType.isAutoSelect() ? "" : MapServiceOptions.getTransportTypeOption(transportType.value()); String options = Stream @@ -91,16 +117,29 @@ public String getOptions(String locationSetName, TransportType transportType) { return options.isEmpty() ? "" : options; } - private static List resolveTransportTypes(Optional transportType) { + private static TransportType resolveTransportType(Optional transportType) { return transportType + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(TransportType::of) + .orElse(TransportType.CAR); + } + + private static List resolveAllowedTransportTypes(Optional allowedTransportTypes) { + List allowed = allowedTransportTypes .map(value -> Arrays.stream(value.split(",")) .map(String::trim) .filter(part -> !part.isEmpty()) .map(TransportType::of) .distinct() .toList()) - .filter(list -> !list.isEmpty()) - .orElseGet(() -> List.of(TransportType.CAR)); + .orElseGet(List::of); + if (allowed.stream().anyMatch(TransportType::isAutoSelect)) { + throw new IllegalArgumentException( + "The transport type (%s) cannot be part of the allowed transport types (%s); it is not a routing profile." + .formatted(TransportType.AUTO_SELECT, allowed)); + } + return allowed; } } diff --git a/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java b/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java index 42ea6f3bcd0..3bb9a989ebf 100644 --- a/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java +++ b/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java @@ -5,6 +5,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -15,6 +16,7 @@ import ai.timefold.solver.service.definition.internal.error.TimefoldRuntimeException; import ai.timefold.solver.service.maps.api.DistanceMatrix; import ai.timefold.solver.service.maps.api.model.Location; +import ai.timefold.solver.service.maps.api.model.TransportType; import ai.timefold.solver.service.maps.api.model.travel.TravelDistance; import ai.timefold.solver.service.maps.api.model.travel.TravelTime; import ai.timefold.solver.service.maps.service.client.api.model.TravelTimesByTimeframeWithMetadata; @@ -34,7 +36,7 @@ class TravelTimeMatrixEnricherTrafficTest { private final MapServiceOptionsSupplier optionsSupplier = new MapServiceOptionsSupplier( Optional.empty(), Optional.empty(), Optional.of(1000.0), Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty(), Optional.empty()); + Optional.empty(), Optional.empty(), Optional.empty()); @Test void regularModelUsesSingleMatrix() { @@ -145,6 +147,77 @@ void wrapsNonTimefoldExceptionFromMapService() { .hasMessageContaining("Error getting travel time and distances"); } + @Test + void autoSelectFetchesOneMatrixPerDatasetTransportType() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + StubMapService stub = new StubMapService(null, + new TravelTimeAndDistanceWithMetadata( + new TravelTimeAndDistance(matrixOf(l1, l2, 75L), matrixOf(l1, l2, 750L)), List.of())); + TravelTimeMatrixEnricher enricher = + new TravelTimeMatrixEnricher(stub, autoSelectOptionsSupplier(null), new MapEnrichmentContext(), false); + + enricher.enrich(new StubLocationsModel(List.of(l1, l2), List.of(TransportType.BICYCLE, TransportType.CAR))); + + // One round-trip per transport type used by the dataset, car first because it is the primary one. + assertThat(stub.singleInvocationCount.get()).isEqualTo(2); + assertThat(stub.singleInvocationOptions) + .containsExactly("transportType:car", "transportType:bicycle"); + } + + @Test + void autoSelectWithoutDatasetTransportTypesFallsBackToTheDefaultOne() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + StubMapService stub = new StubMapService(null, + new TravelTimeAndDistanceWithMetadata( + new TravelTimeAndDistance(matrixOf(l1, l2, 75L), matrixOf(l1, l2, 750L)), List.of())); + TravelTimeMatrixEnricher enricher = + new TravelTimeMatrixEnricher(stub, autoSelectOptionsSupplier(null), new MapEnrichmentContext(), false); + + enricher.enrich(new StubLocationsModel(List.of(l1, l2))); + + assertThat(stub.singleInvocationOptions).containsExactly("transportType:car"); + } + + @Test + void autoSelectRejectsTransportTypeNotAllowed() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(new StubMapService(null, null), + autoSelectOptionsSupplier("car,foot"), new MapEnrichmentContext(), false); + StubLocationsModel model = new StubLocationsModel(List.of(l1, l2), List.of(TransportType.BICYCLE)); + + assertThatThrownBy(() -> enricher.enrich(model)) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining("bicycle") + .hasMessageContaining("car, foot"); + } + + @Test + void fixedTransportTypeRejectsDatasetUsingAnotherTransportType() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(new StubMapService(null, null), + optionsSupplierOf("car", null), new MapEnrichmentContext(), false); + StubLocationsModel model = new StubLocationsModel(List.of(l1, l2), List.of(TransportType.CAR, TransportType.FOOT)); + + assertThatThrownBy(() -> enricher.enrich(model)) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining("foot") + .hasMessageContaining("auto-select"); + } + + private static MapServiceOptionsSupplier autoSelectOptionsSupplier(String allowedTransportTypes) { + return optionsSupplierOf("auto-select", allowedTransportTypes); + } + + private static MapServiceOptionsSupplier optionsSupplierOf(String transportType, String allowedTransportTypes) { + return new MapServiceOptionsSupplier(Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.ofNullable(transportType), Optional.ofNullable(allowedTransportTypes)); + } + private static DistanceMatrix matrixOf(Location from, Location to, long value) { DistanceMatrix matrix = DistanceMatrix.getInstance(2); matrix.put(from, to, value); @@ -158,6 +231,7 @@ private static final class StubMapService implements MapService { private final TravelTimeAndDistanceWithMetadata singleResult; private final AtomicInteger byTimeframeInvocationCount = new AtomicInteger(0); private final AtomicInteger singleInvocationCount = new AtomicInteger(0); + private final List singleInvocationOptions = new ArrayList<>(); private List lastLocations; StubMapService(TravelTimesByTimeframeWithMetadata byTimeframeResult, @@ -169,6 +243,7 @@ private static final class StubMapService implements MapService { @Override public TravelTimeAndDistanceWithMetadata getTravelTimeAndDistance(List locations, String options) { singleInvocationCount.incrementAndGet(); + singleInvocationOptions.add(options); if (singleResult == null) { throw new UnsupportedOperationException("single matrix result not configured"); } @@ -224,10 +299,21 @@ public List getLocationsOutOfMap(List locations, String optio private static class StubLocationsModel implements LocationsAwareSolverModel { private final List locations; + private final List transportTypes; private List notInMap; StubLocationsModel(List locations) { + this(locations, List.of()); + } + + StubLocationsModel(List locations, List transportTypes) { this.locations = locations; + this.transportTypes = transportTypes; + } + + @Override + public List getTransportTypes() { + return transportTypes; } @Override diff --git a/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/api/LocationsAwareSolverModel.java b/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/api/LocationsAwareSolverModel.java index 5ab322f3cbe..b841af92377 100644 --- a/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/api/LocationsAwareSolverModel.java +++ b/service/maps/service-integration/src/main/java/ai/timefold/solver/service/maps/service/integration/api/LocationsAwareSolverModel.java @@ -6,11 +6,16 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.service.definition.api.SolverModel; import ai.timefold.solver.service.maps.api.model.Location; +import ai.timefold.solver.service.maps.api.model.TransportType; public interface LocationsAwareSolverModel> extends SolverModel { List getLocations(); + default List getTransportTypes() { + return List.of(); + } + Optional getLocationSetName(); void setLocationsNotInMap(List locationsNotInMap); From fa23335acf2ac051059d504cd125b709583e4ff4 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Thu, 24 Sep 2026 11:13:29 +0200 Subject: [PATCH 08/10] feat: Style issues --- .../maps/service/client/api/TravelTimeMatrixEnricher.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index ca5bd9683f0..7462c44ea79 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -163,7 +163,7 @@ private List resolveTransportTypes(LocationsAwareSolverModel s .sorted() // Deterministic order; CAR, when used, comes first and is therefore the primary one. .toList(); if (!optionsSupplier.isAutoSelectTransportType()) { - TransportType configuredTransportType = optionsSupplier.getTransportType(); + var configuredTransportType = optionsSupplier.getTransportType(); failIfNotAllowed(List.of(configuredTransportType)); List unsupported = datasetTransportTypes.stream() .filter(transportType -> transportType != configuredTransportType) From 192f7da674644530aa41352f263fb065e564213e Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Thu, 24 Sep 2026 14:59:58 +0200 Subject: [PATCH 09/10] feat: make empty transport type the default and convert to transport type only in solver --- .../client/api/TravelTimeMatrixEnricher.java | 15 ++++++++ .../impl/MapServiceOptionsSupplier.java | 5 ++- .../TravelTimeMatrixEnricherTrafficTest.java | 38 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java index 7462c44ea79..8effe30232c 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricher.java @@ -92,6 +92,13 @@ private void enrichSingleMatrix(LocationsAwareSolverModel solverModel, Transp locations.forEach(location -> { location.setTravelTimeMatrix(transportType, travelTimeAndDistance.travelTimeAndDistance().travelTime()); location.setDistanceMatrix(transportType, travelTimeAndDistance.travelTimeAndDistance().distance()); + if (primary) { + // The primary transport type also fills the transport-type-less matrices, so that lookups which do + // not specify one keep working. Without this, a deployment configured for a single non-default + // transport type would leave them empty and every such lookup would fail. + location.setTravelTimeMatrix(travelTimeAndDistance.travelTimeAndDistance().travelTime()); + location.setDistanceMatrix(travelTimeAndDistance.travelTimeAndDistance().distance()); + } }); if (primary) { solverModel @@ -122,11 +129,19 @@ private void enrichAllTimeframes(LocationsAwareSolverModel solverModel, Trans for (Location location : locations) { location.setTravelTimeMatrix(transportType, travelTimes[0]); location.setDistanceMatrix(transportType, distances[0]); + if (primary) { + location.setTravelTimeMatrix(travelTimes[0]); + location.setDistanceMatrix(distances[0]); + } } } else { for (Location location : locations) { location.setTravelTimeMatrices(transportType, travelTimes, result.timeframeIndexResolver()); location.setDistanceMatrices(transportType, distances, result.timeframeIndexResolver()); + if (primary) { + location.setTravelTimeMatrices(travelTimes, result.timeframeIndexResolver()); + location.setDistanceMatrices(distances, result.timeframeIndexResolver()); + } } } if (primary) { diff --git a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java index dffaff01c34..676468f1d2e 100644 --- a/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java +++ b/service/maps/service-client/src/main/java/ai/timefold/solver/service/maps/service/client/impl/MapServiceOptionsSupplier.java @@ -77,6 +77,9 @@ public boolean isAllowed(TransportType transportType) { } public TransportType getDefaultTransportType() { + if (!isAutoSelectTransportType()) { + return transportType; + } if (isAllowed(TransportType.CAR)) { return TransportType.CAR; } @@ -88,7 +91,7 @@ public String getOptions() { } public String getOptions(String locationSetName) { - return getOptions(locationSetName, isAutoSelectTransportType() ? getDefaultTransportType() : transportType); + return getOptions(locationSetName, getDefaultTransportType()); } public String getOptions(TransportType transportType) { diff --git a/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java b/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java index 3bb9a989ebf..fc4ae177378 100644 --- a/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java +++ b/service/maps/service-client/src/test/java/ai/timefold/solver/service/maps/service/client/api/TravelTimeMatrixEnricherTrafficTest.java @@ -180,6 +180,44 @@ void autoSelectWithoutDatasetTransportTypesFallsBackToTheDefaultOne() { assertThat(stub.singleInvocationOptions).containsExactly("transportType:car"); } + @Test + void singleNonDefaultTransportTypeAlsoFillsTheTransportTypeLessMatrices() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + StubMapService stub = new StubMapService(null, + new TravelTimeAndDistanceWithMetadata( + new TravelTimeAndDistance(matrixOf(l1, l2, 75L), matrixOf(l1, l2, 750L)), List.of())); + TravelTimeMatrixEnricher enricher = new TravelTimeMatrixEnricher(stub, optionsSupplierOf("bicycle", null), + new MapEnrichmentContext(), false); + + enricher.enrich(new StubLocationsModel(List.of(l1, l2), List.of(TransportType.BICYCLE))); + + assertThat(stub.singleInvocationOptions).containsExactly("transportType:bicycle"); + assertThat(l1.getTravelTimeTo(l2, TransportType.BICYCLE)).isEqualTo(TravelTime.of(75L)); + // Callers that do not state a transport type (e.g. a nearby distance meter comparing two unassigned visits) + // must still get an answer; otherwise a bicycle-only deployment fails on every such lookup. + assertThat(l1.getTravelTimeTo(l2)).isEqualTo(TravelTime.of(75L)); + assertThat(l1.getDistanceTo(l2)).isEqualTo(TravelDistance.of(750L)); + assertThat(l1.getTravelTimeTo(l2, MORNING_AT)).isEqualTo(TravelTime.of(75L)); + } + + @Test + void autoSelectFillsTheTransportTypeLessMatricesFromThePrimaryTransportType() { + Location l1 = new Location(0, 0); + Location l2 = new Location(1, 1); + StubMapService stub = new StubMapService(null, + new TravelTimeAndDistanceWithMetadata( + new TravelTimeAndDistance(matrixOf(l1, l2, 75L), matrixOf(l1, l2, 750L)), List.of())); + TravelTimeMatrixEnricher enricher = + new TravelTimeMatrixEnricher(stub, autoSelectOptionsSupplier(null), new MapEnrichmentContext(), false); + + enricher.enrich(new StubLocationsModel(List.of(l1, l2), List.of(TransportType.BICYCLE, TransportType.FOOT))); + + // Bicycle sorts first, so it is the primary one and the one a transport-type-less lookup resolves to. + assertThat(stub.singleInvocationOptions).containsExactly("transportType:bicycle", "transportType:foot"); + assertThat(l1.getTravelTimeTo(l2)).isEqualTo(TravelTime.of(75L)); + } + @Test void autoSelectRejectsTransportTypeNotAllowed() { Location l1 = new Location(0, 0); From bbf4d33878e8eb299645059df7ac39a495eec125 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Thu, 24 Sep 2026 15:25:27 +0200 Subject: [PATCH 10/10] feat: add documentation about transport types --- .../deploying-to-platform/model-metadata.adoc | 2 + .../service/model-enrichment.adoc | 91 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc b/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc index a10c01606bf..55366a6dd38 100644 --- a/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc +++ b/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc @@ -175,6 +175,8 @@ This profile is what consumers get out of the box, before they add any profile o | `timefold.model.default-config.map.transport-type` | Default transport type used when computing distances, for models that require map data. +One of `car` (the default), `bicycle`, `foot`, or `auto-select` to take the transport types from the dataset itself. +See xref:running-timefold-solver/service/model-enrichment.adoc#transportTypes[Transport types]. | `timefold.model.default-config.map.use-traffic` | Whether the default profile takes live traffic into account. Defaults to `false`. diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-enrichment.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-enrichment.adoc index 69a994c44a4..e2020ffcdf4 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-enrichment.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-enrichment.adoc @@ -147,6 +147,97 @@ class TimeslotHolidayEnricher : SolverModelEnricher { -- ==== +[#transportTypes] +== Transport types + +Models whose solution implements `LocationsAwareSolverModel` are enriched by the built-in `TravelTimeMatrixEnricher`, +which fetches travel time and distance matrices from the map service and stores them on each `Location`. + +Which routing profile those matrices describe is controlled by the transport type: + +[cols="1,3"] +|=== +| Value | Meaning + +| `car` +| The default. Distances follow the road network as driven by a car. + +| `bicycle` +| Distances follow the cycling network. + +| `foot` +| Distances follow the walking network. + +| `auto-select` +| Not a routing profile. The transport types are taken from the dataset instead; see <>. +|=== + +Set it with the `timefold.platform.map-service.transport-type` property. +A deployment may additionally be restricted to a subset of transport types with +`timefold.platform.map-service.allowed-transport-types`, a comma-separated list; when it is not set, every transport +type is allowed. + +A dataset that uses a transport type other than the configured one is rejected before any map service call is made, +because a deployment only has the map for the profile it is configured with. + +[#readingMatrices] +=== Reading travel times and distances + +`Location` exposes an overload of each lookup that takes the transport type. +Use it whenever your model distinguishes between transport types: + +[source,java,options="nowrap"] +---- +TravelTime travelTime = origin.getTravelTimeTo(destination, TransportType.BICYCLE); +TravelDistance distance = origin.getDistanceTo(destination, TransportType.BICYCLE); + +// The traffic-aware overloads take the departure time as well. +TravelTime atNoon = origin.getTravelTimeTo(destination, departureTime, TransportType.BICYCLE); +---- + +The overloads without a transport type keep working and resolve to the primary transport type of the deployment, +which is defined in <>. + +If your model stores matrices itself rather than relying on the enricher, the setters take the transport type too: + +[source,java,options="nowrap"] +---- +location.setTravelTimeMatrix(TransportType.BICYCLE, travelTimeMatrix); +location.setDistanceMatrix(TransportType.BICYCLE, distanceMatrix); +---- + +[#autoSelect] +=== Routing one dataset with several transport types + +With `timefold.platform.map-service.transport-type=auto-select`, the transport types are not fixed by configuration. +The enricher asks the solution which ones the dataset actually uses, by overriding `getTransportTypes()`: + +[source,java,options="nowrap"] +---- +@Override +public List getTransportTypes() { + return vehicles.stream() + .map(Vehicle::getTransportType) + .filter(Objects::nonNull) + .distinct() + .toList(); +} +---- + +Each of those transport types is validated against the allowed transport types and then fetched in its own map service +request, so a dataset using two transport types results in two requests and two sets of matrices per `Location`. +A dataset that uses a transport type the deployment is not allowed to route with is rejected before any request is made. +When the solution returns an empty list, the default transport type is used. + +One of the transport types is the *primary* one: the configured one when it is fixed, otherwise `car` if the dataset +uses it, and the first one in declaration order otherwise. The primary transport type is special in two ways: + +* The model-level map metadata — the locations that are not in the map, and the resolved map region — comes from its +request only. +* Its matrices also back the lookups that do not name a transport type, so those keep answering on a deployment +configured for a single non-default transport type. + + [#enrichmentDirector] == Controlling enrichment order