Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<OffsetDateTime> timeframeIndexResolver;

public Location() {
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -80,6 +96,18 @@ public void setDistanceMatrix(DistanceMatrix distanceMatrix) {
updateIndex(this.distanceMatrix);
}

public void setTravelTimeMatrices(DistanceMatrix[] travelTimesByTimeframe,
ToIntFunction<OffsetDateTime> indexResolver) {
this.travelTimesByTimeframe = travelTimesByTimeframe;
this.timeframeIndexResolver = indexResolver;
}

public void setDistanceMatrices(DistanceMatrix[] distancesByTimeframe,
ToIntFunction<OffsetDateTime> indexResolver) {
this.distancesByTimeframe = distancesByTimeframe;
this.timeframeIndexResolver = indexResolver;
}

/**
* Returns the travel time for a route between this location and the given location.
*
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}

}
Original file line number Diff line number Diff line change
@@ -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<OffsetDateTime> 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");
}

}
Original file line number Diff line number Diff line change
@@ -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<Location> 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<Location> locations, String options,
Map<Location, List<TimeInterval>> timeAvailability);

List<Location> getWaypoints(List<Location> locations, String options);

List<Integer> getLocationsOutOfMap(List<Location> locations, String options);
Expand Down
Loading
Loading