Skip to content

feat: add support for maps with traffic#2320

Open
diogodanielsoaresferreira wants to merge 10 commits into
TimefoldAI:mainfrom
diogodanielsoaresferreira:add_support_maps_with_traffic
Open

feat: add support for maps with traffic#2320
diogodanielsoaresferreira wants to merge 10 commits into
TimefoldAI:mainfrom
diogodanielsoaresferreira:add_support_maps_with_traffic

Conversation

@diogodanielsoaresferreira

@diogodanielsoaresferreira diogodanielsoaresferreira commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for traffic-aware travel times in the maps model. Travel-time matrices can now be enriched with time-of-day traffic information using configurable time-frame bucketing strategies, enabling solver models to plan with realistic, traffic-sensitive routing.

Changes

New opt-in enrichment path for solver models whose travel times and distances vary with time of day. Existing LocationsAwareSolverModel + TravelTimeMatrixEnricher untouched.

  • the usage of matrices with traffic is decided by the model based on which solver model is used (LocationsAndTrafficAwareSolverModel or LocationsAwareSolverModel); enricher supports both.
  • new TimeframeBucketing interface + two impls: StaticDaypartBucketing (morning 00–12, midday 12–15, afternoon 15+) and SingleTimeframeBucketing (one key, used by Haversine).
  • New MapService method taking an availability map: applies the bucketing, fans out one concurrent bundled call per timeframe, caches the composite.
  • for now, getWaypoints still use a map with timeframe "static", which means that for models to use it, we always need the 3 timeframe maps + static one

@rsynek

rsynek commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

To revive the open point from https://github.com/TimefoldAI/timefold-models-sdk/pull/975, let me copy here this summary:

model interface traffic enabled data fetched how to fetch
LocationsAware off one matrix, no traffic getTravelTimeTo(other)
LocationsAware on one matrix, traffic at default timeframe getTravelTimeTo(other)
LocationsAndTrafficAware on N matrices with N timeframes getTravelTimeTo(other, t)
LocationsAndTrafficAware off one matrix wrapped as 1 timeframe for the whole day getTravelTimeTo(other, t)

I see a problem with the second row. We should still return N matrices with N timeframes. Opposed to row number 3, the matrices will be larger, as we cannot prune them by the location availabilities.

@rsynek

rsynek commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Ad "for now, getWaypoints still use a map with timeframe "static", which means that for models to use it, we always need the 3 timeframe maps + static one".

Does it mean we use different routes for computing the travel time (assuming the traffic data leads to different routes) than we show in the waypoints?

Discussed separately:

  • The current OSRM call for waypoints does not support any departure times.
  • Waypoints are used only for visualizations (and occasionally for debugging).
  • Customer rather use dedicated routing apps.
  • For now, we update the waypoints endpoint description to clearly state waypoints are for illustration only and with traffic data enabled, the routes they show might not match the routes used for travel time and distance calculation.

@diogodanielsoaresferreira

Copy link
Copy Markdown
Contributor Author

To revive the open point from TimefoldAI/timefold-models-sdk#975, let me copy here this summary:
model interface traffic enabled data fetched how to fetch
LocationsAware off one matrix, no traffic getTravelTimeTo(other)
LocationsAware on one matrix, traffic at default timeframe getTravelTimeTo(other)
LocationsAndTrafficAware on N matrices with N timeframes getTravelTimeTo(other, t)
LocationsAndTrafficAware off one matrix wrapped as 1 timeframe for the whole day getTravelTimeTo(other, t)

I see a problem with the second row. We should still return N matrices with N timeframes. Opposed to row number 3, the matrices will be larger, as we cannot prune them by the location availabilities.

Why do we want to do this? my idea was that the model interface would be a clear separation between using/not using timestamps: if using LocationsAndTrafficAware, a departure time would always have to be sent to getTravelTime; if not, a departure time could not be sent. If we want to allow to have LocationsAware with multiple matrices, then we will have to change that assumption.

So the new table would be something like this:

model interface traffic enabled data fetched how to fetch
LocationsAware off one matrix, no traffic getTravelTimeTo(other), getTravelTimeTo(other, t)
LocationsAware on N matrices with N timeframes (not pruned) getTravelTimeTo(other), getTravelTimeTo(other, t)
LocationsAndTrafficAware on N matrices with N timeframes (pruned) getTravelTimeTo(other), getTravelTimeTo(other, t)
LocationsAndTrafficAware off one matrix wrapped as 1 timeframe for the whole day getTravelTimeTo(other), getTravelTimeTo(other, t)
  • if traffic is enabled, we will always fetch NxN matrices
  • clients can always fetch a travelTime using or not using departureTime: this will not fail, but it will accept everything silently and redirect to the correct matrix
  • If only one matrix is available, getTravelTimeTo(other, t) will use that matrix
  • If more than one matrix is available, getTravelTimeTo(other) will use some default matrix

@rsynek is this your idea?

@rsynek

rsynek commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Ad one matrix, no traffic vs. one matrix wrapped as 1 timeframe for the whole day : from users' perspective, it's the same thing - we don't have traffic data. There should be no difference in performance.

Ad LocationsAndTrafficAware interface : given we allow traffic data regardless of the pruning being implemented, I think we need a better name. It's not about traffic, it's about the time availability of locations (but only relevant for traffic data).

Ad "clear separation between using/not using timestamps" : LocationsAware or LocationsAndTrafficAware are used at pre-processing/enrichment time. Using or not using departure time to get the travelTime or distance happens at runtime through the Location interface.

Ad "clients can always fetch a travelTime using or not using departureTime: this will not fail, but it will accept everything silently and redirect to the correct matrix" : if traffic data is enabled, we could write a warning when getTravelTime(otherLocation) is used.

General requirements

  • minimal migration path to enable traffic data; from getTravelTime(location) to getTravelTime(location, time)
  • pruning is extra boilerplate code; not always possible (optional)
  • model that supports traffic data also supports flat data, but does not require changing between getTravelTime(location) and getTravelTime(location, time) back and forth, since the choice is per request

@diogodanielsoaresferreira

Copy link
Copy Markdown
Contributor Author

@rsynek Those requirements should be covered by the last commit, so:

  • getTravelTimeTo(other) and getTravelTimeTo(other, t) can be used interchangeably
  • Pruning is not required; the flag use-traffic is what differentiates if maps with traffic will be used or not

@rsynek rsynek left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the adjustments!

I am sharing some comments inline; please have also look at the SonarCloud issues. Not saying we have to always change the code according to SonarCloud, but let's consider them and if it's the expected behavior, I can mark the issues as false positives.

I think primarily the ones about equals() and hashcode() in records with arrays are pulling the overall mark down.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's Boolean, should the default value be false?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change to false for completeness sake. Done

Comment on lines +20 to +25
* @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()}.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: usually, return is typically a one liner, summarizing what the method returns. Longer texts explaining the logic in details better fir the general description of the method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, moved most text to the description of the method.

List<Location> locationsNotInMap) {
}

private DistanceMatrix zeroMatrixFor(List<Location> locations) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thinks this SonarCloud check is relevant: if the method is static, just be looking at the declaration, the reader knows it's a pure function and does not touch the instance fields. Useful for debugging, especially in case multi-threaded computing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to be a static method.

return new AssembledTimeframedMatrices(travelTimesByTimeframe, distancesByTimeframe, locationsNotInMap);
}

private record AssembledTimeframedMatrices(DistanceMatrix[] travelTimesByTimeframe,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Sonar issue puts down the mark to C, so better to resolve it.

The way records handle arrays in equals(), hashCode() or toString(), is using purely the reference (and not the content).

What is the expected equals() and hashCode() contract for this class?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added those methods. This class is only used as a wrapper for communication between the client and the enricher, but it doesn't hurt to add those.

CompletableFuture<TravelTimeAndDistanceWithMetadata>[] futures = new CompletableFuture[n];
List<Location>[] subsets = new List[n];

for (int idx = 0; idx < n; idx++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

n does not tell much. Caching the size as in int n = allTimeframes.size(); is a premature optimization that reduces readability.

Collections implement size() in an efficient way that does not require caching:
https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ArrayList.java#L253

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the name to allTimeframesSize.

CompletableFuture<?>[] pending = Arrays.stream(futures).filter(Objects::nonNull)
.toArray(CompletableFuture<?>[]::new);
if (pending.length > 0) {
CompletableFuture.allOf(pending).join();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the code blocks here until all threads finish, is that correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the code blocks until all threads finish. We don't have any upper bound time limit for single calls, since they can take a long time, so I opted to also not add it here.

* {@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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same concern as in the other record; the equals and hashcode contract for arrays.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added equals, hashcode and toString.

Comment on lines +144 to +146
* ({@link #setTravelTimeMatrices(DistanceMatrix[], ToIntFunction)}) or with a single matrix
* ({@link #setTravelTimeMatrix(DistanceMatrix)}): the per-timeframe matrix for {@code departureTime} is used when
* timeframe matrices are configured, otherwise the single matrix is used and {@code departureTime} is ignored.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: for API classes, I would talk in general terms (traffic data was requested or not) and avoid implementation details.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, removed the implementation details.

*/
public TravelDistance getDistanceTo(Location location) {
if (distanceMatrix == null) {
DistanceMatrix matrix = distanceMatrix != null ? distanceMatrix : firstAvailableMatrix(distancesByTimeframe);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the firstAvailableMatrix is there for the situation when traffic data was enabled, but we don't ask for it, correct?

Instead of finding the first available matrix on the hot path, why don't we select the right one and set it as the distanceMatrix reference during enrichment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's correct. We don't do that because we don't know which is the "right" one, that's why we just take the first. We can do this in the enricher and set the first matrix as the "single" matrix, although it's a tradeoff: we get the optimizations for single matrix, but the object will take more memory (3 matrices + 1 repeated matrix for requests without timestamp).

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
35.3% Coverage on New Code (required ≥ 70%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants