Real-time planning: pinning visits
Real-time planning can be disruptive to technician’s schedules. Real-time planning is also not always possible, for instance:
-
Customers might only be available to provide access to the property at certain times, and the visit can only be completed during a specific time window.
-
A technician might have equipment or materials for a specific visit.
Visits that have already been assigned and scheduled can be pinned to prevent them from being rescheduled.
Prerequisites
Learn how to configure an API Key to run the examples in this guide:
-
Log in to Timefold Platform: app.timefold.ai.
-
From the Dashboard, click your tenant, and from the drop-down menu select Tenant Settings, then choose API Keys.
-
Create a new API key or use an existing one. Ensure the list of models for the API key contains the current model.
In the examples, replace <API_KEY>
with the API Key you just copied.
1. Pinning with freezeTime
In the Real-time planning: emergency visit example, Ann had three visits scheduled for the day, while Beth had one. An emergency visit, Visit M, needed to be added to the schedule. The emergency visit was close to Ann’s existing route, and so the real-time plan reassigned Ann’s final visit of the day, Visit E, to Beth, and Ann was assigned the emergency Visit M.
However, this may not always be the best solution.
If Ann already has the material for Visit E loaded in her van, the visit cannot be assigned to another technician.
In this case, Visit E can be pinned so that it remains assigned to Ann, and the emergency visit, Visit M, will be assigned to Beth.
Real-time planning pins all visits before freezeTime
, including visits that technicians are already traveling to.
Pinning additional visits gives you greater control over specific visits after the time specified in freezeTime
.
1.1. Batch schedule: pinning
The original schedule was generated from the following input dataset during the regular batch scheduling:
-
Input
-
Output
Try this example in Timefold Platform by saving this JSON into a file called sample.json and make the following API call:
|
curl -X POST -H "Content-type: application/json" -H 'X-API-KEY: <API_KEY>' https://app.timefold.ai/api/models/field-service-routing/v1/route-plans [email protected]
{
"config": {
"run": {
"name": "Original shift plan: pinning example"
}
},
"modelInput": {
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startLocation": [33.68786, -84.18487],
"minStartTime": "2027-02-01T09:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z"
}
]
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startLocation": [33.70474, -84.06508],
"minStartTime": "2027-02-01T09:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z"
}
]
}
],
"visits": [
{
"id": "Visit A",
"location": [33.84475, -84.63649],
"serviceDuration": "PT1H30M"
},
{
"id": "Visit E",
"location": [33.90719, -84.28149],
"serviceDuration": "PT1H30M"
},
{
"id": "Visit B",
"location": [33.67590, -84.11845],
"serviceDuration": "PT1H30M"
},
{
"id": "Visit C",
"location": [33.76156, -84.27259],
"serviceDuration": "PT1H30M"
}
]
}
}
To request the solution, locate the ID from the response to the post operation and append it to the following API call:
|
curl -X GET -H 'X-API-KEY: <API_KEY>' https://app.timefold.ai/api/models/field-service-routing/v1/route-plans/<ID>
{
"metadata": {
"id": "ID",
"name": "Original shift plan: pinning example",
"submitDateTime": "2024-12-11T04:47:09.396739074Z",
"startDateTime": "2024-12-11T04:47:16.153390108Z",
"activeDateTime": "2024-12-11T04:47:16.463899915Z",
"completeDateTime": "2024-12-11T04:52:16.530666888Z",
"shutdownDateTime": "2024-12-11T04:52:16.857617623Z",
"solverStatus": "SOLVING_COMPLETED",
"score": "0hard/0medium/-157696soft",
"tags": null,
"validationResult": {
"summary": "OK"
}
},
"modelOutput": {
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startTime": "2027-02-01T09:00:00Z",
"itinerary": [
{
"id": "Visit C",
"kind": "VISIT",
"arrivalTime": "2027-02-01T09:18:16Z",
"startServiceTime": "2027-02-01T09:18:16Z",
"departureTime": "2027-02-01T10:48:16Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT18M16S",
"travelDistanceMetersFromPreviousStandstill": 15033,
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit A",
"kind": "VISIT",
"arrivalTime": "2027-02-01T11:30:36Z",
"startServiceTime": "2027-02-01T11:30:36Z",
"departureTime": "2027-02-01T13:00:36Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT42M20S",
"travelDistanceMetersFromPreviousStandstill": 44028,
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit E",
"kind": "VISIT",
"arrivalTime": "2027-02-01T13:41:09Z",
"startServiceTime": "2027-02-01T13:41:09Z",
"departureTime": "2027-02-01T15:11:09Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT40M33S",
"travelDistanceMetersFromPreviousStandstill": 42302,
"minStartTravelTime": "2027-02-01T00:00:00Z"
}
],
"metrics": {
"totalTravelTime": "PT2H8M57S",
"travelTimeFromStartLocationToFirstVisit": "PT18M16S",
"travelTimeBetweenVisits": "PT1H22M53S",
"travelTimeFromLastVisitToEndLocation": "PT27M48S",
"totalTravelDistanceMeters": 130583,
"travelDistanceFromStartLocationToFirstVisitMeters": 15033,
"travelDistanceBetweenVisitsMeters": 86330,
"travelDistanceFromLastVisitToEndLocationMeters": 29220,
"endLocationArrivalTime": "2027-02-01T15:38:57Z"
}
}
]
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startTime": "2027-02-01T09:00:00Z",
"itinerary": [
{
"id": "Visit B",
"kind": "VISIT",
"arrivalTime": "2027-02-01T09:10:37Z",
"startServiceTime": "2027-02-01T09:10:37Z",
"departureTime": "2027-02-01T10:40:37Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT10M37S",
"travelDistanceMetersFromPreviousStandstill": 8843,
"minStartTravelTime": "2027-02-01T00:00:00Z"
}
],
"metrics": {
"totalTravelTime": "PT21M15S",
"travelTimeFromStartLocationToFirstVisit": "PT10M37S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT10M38S",
"totalTravelDistanceMeters": 17677,
"travelDistanceFromStartLocationToFirstVisitMeters": 8843,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 8834,
"endLocationArrivalTime": "2027-02-01T10:51:15Z"
}
}
]
}
]
},
"kpis": {
"totalTravelTime": "PT2H30M12S",
"travelTimeFromStartLocationToFirstVisit": "PT28M53S",
"travelTimeBetweenVisits": "PT1H22M53S",
"travelTimeFromLastVisitToEndLocation": "PT38M26S",
"totalTravelDistanceMeters": 148260,
"travelDistanceFromStartLocationToFirstVisitMeters": 23876,
"travelDistanceBetweenVisitsMeters": 86330,
"travelDistanceFromLastVisitToEndLocationMeters": 38054,
"totalUnassignedVisits": 0
}
}
modelOutput
contains Ann’s and Beth’s shift itineraries.
1.2. Real-time planning update: pinning
A little before midday, an emergency visit needs to be included in the plan for the same day.
Update the input dataset with the emergency visit (Visit M) included.
Because Visit M is a critical priority, it includes "priority": "1"
.
The already planned visits need to be updated with the minStartTravelTime
from the most recent planning output dataset (batch or real-time) to each visit.
Visit M has not already been planned and does not include a minStartTravelTime
value.
Because Visit E must be completed by Ann, add "pinningRequested": true
to Visit E.
Sequences of visits after the |
{
"visits": [
{
"id": "Visit A",
"location": [33.84475, -84.63649],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit E",
"location": [33.90719, -84.28149],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z",
"pinningRequested": true
},
{
"id": "Visit B",
"location": [33.67590, -84.11845],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit C",
"location": [33.76156, -84.27259],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit M",
"location": [33.89104, -84.64711],
"serviceDuration": "PT1H30M",
"priority": "1"
}
]
}
Add the itinerary to Ann’s and Beth’s shifts from the previous output, including the visit IDs and the visit kind:
{
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startLocation": [33.68786, -84.18487],
"minStartTime": "2027-02-01T09:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z",
"itinerary": [
{
"id": "Visit C",
"kind": "VISIT"
},
{
"id": "Visit A",
"kind": "VISIT"
},
{
"id": "Visit E",
"kind": "VISIT"
}
]
}
]
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startLocation": [33.70474, -84.06508],
"minStartTime": "2027-02-01T09:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z",
"itinerary": [
{
"id": "Visit B",
"kind": "VISIT"
}
]
}
]
}
]
}
Freeze visits that have already occurred and that technicians have begun traveling to by adding freezeTime
:
{
"modelInput": {
"freezeTime": "2027-02-01T12:00:00Z"
}
}
The call for the emergency visit came in a little before 12:00 and the freezeTime
is set to 12:00.
Finally, resubmit the updated input dataset:
-
Input
-
Output
Try this example in Timefold Platform by saving this JSON into a file called sample.json and make the following API call:
|
curl -X POST -H "Content-type: application/json" -H 'X-API-KEY: <API_KEY>' https://app.timefold.ai/api/models/field-service-routing/v1/route-plans [email protected]
{
"config": {
"run": {
"name": "Real-time planning: pinning"
}
},
"modelInput": {
"freezeTime": "2027-02-01T12:00:00Z",
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startLocation": [33.68786, -84.18487],
"minStartTime": "2027-02-01T09:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z",
"itinerary": [
{
"id": "Visit C",
"kind": "VISIT"
},
{
"id": "Visit A",
"kind": "VISIT"
},
{
"id": "Visit E",
"kind": "VISIT"
}
]
}
]
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startLocation": [33.70474, -84.06508],
"minStartTime": "2027-02-01T09:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z",
"itinerary": [
{
"id": "Visit B",
"kind": "VISIT"
}
]
}
]
}
],
"visits": [
{
"id": "Visit A",
"location": [33.84475, -84.63649],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit E",
"location": [33.90719, -84.28149],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z",
"pinningRequested": true
},
{
"id": "Visit B",
"location": [33.67590, -84.11845],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit C",
"location": [33.76156, -84.27259],
"serviceDuration": "PT1H30M",
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit M",
"location": [33.89104, -84.64711],
"serviceDuration": "PT1H30M",
"priority": "1"
}
]
}
}
To request the solution, locate the ID from the response to the post operation and append it to the following API call:
|
curl -X GET -H 'X-API-KEY: <API_KEY>' https://app.timefold.ai/api/models/field-service-routing/v1/route-plans/<ID>
{
"metadata": {
"id": "ID",
"name": "Real-time planning: pinning",
"submitDateTime": "2024-12-11T07:17:44.905391784Z",
"startDateTime": "2024-12-11T07:17:55.039550854Z",
"activeDateTime": "2024-12-11T07:17:55.744761354Z",
"completeDateTime": "2024-12-11T07:21:09.771021593Z",
"shutdownDateTime": "2024-12-11T07:21:10.102426256Z",
"solverStatus": "SOLVING_COMPLETED",
"score": "0hard/0medium/-297231soft",
"tags": null,
"validationResult": {
"summary": "OK"
}
},
"modelOutput": {
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startTime": "2027-02-01T09:00:00Z",
"itinerary": [
{
"id": "Visit C",
"kind": "VISIT",
"arrivalTime": "2027-02-01T09:18:16Z",
"startServiceTime": "2027-02-01T09:18:16Z",
"departureTime": "2027-02-01T10:48:16Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT18M16S",
"travelDistanceMetersFromPreviousStandstill": 15033,
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit A",
"kind": "VISIT",
"arrivalTime": "2027-02-01T11:30:36Z",
"startServiceTime": "2027-02-01T11:30:36Z",
"departureTime": "2027-02-01T13:00:36Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT42M20S",
"travelDistanceMetersFromPreviousStandstill": 44028,
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit E",
"kind": "VISIT",
"arrivalTime": "2027-02-01T13:41:09Z",
"startServiceTime": "2027-02-01T13:41:09Z",
"departureTime": "2027-02-01T15:11:09Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT40M33S",
"travelDistanceMetersFromPreviousStandstill": 42302,
"minStartTravelTime": "2027-02-01T00:00:00Z"
}
],
"metrics": {
"totalTravelTime": "PT2H8M57S",
"travelTimeFromStartLocationToFirstVisit": "PT18M16S",
"travelTimeBetweenVisits": "PT1H22M53S",
"travelTimeFromLastVisitToEndLocation": "PT27M48S",
"totalTravelDistanceMeters": 130583,
"travelDistanceFromStartLocationToFirstVisitMeters": 15033,
"travelDistanceBetweenVisitsMeters": 86330,
"travelDistanceFromLastVisitToEndLocationMeters": 29220,
"endLocationArrivalTime": "2027-02-01T15:38:57Z"
}
}
]
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startTime": "2027-02-01T09:00:00Z",
"itinerary": [
{
"id": "Visit B",
"kind": "VISIT",
"arrivalTime": "2027-02-01T09:10:37Z",
"startServiceTime": "2027-02-01T09:10:37Z",
"departureTime": "2027-02-01T10:40:37Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT10M37S",
"travelDistanceMetersFromPreviousStandstill": 8843,
"minStartTravelTime": "2027-02-01T00:00:00Z"
},
{
"id": "Visit M",
"kind": "VISIT",
"arrivalTime": "2027-02-01T13:03:05Z",
"startServiceTime": "2027-02-01T13:03:05Z",
"departureTime": "2027-02-01T14:33:05Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT1H3M5S",
"travelDistanceMetersFromPreviousStandstill": 69591,
"minStartTravelTime": "2027-02-01T12:00:00Z"
}
],
"metrics": {
"totalTravelTime": "PT2H17M24S",
"travelTimeFromStartLocationToFirstVisit": "PT10M37S",
"travelTimeBetweenVisits": "PT1H3M5S",
"travelTimeFromLastVisitToEndLocation": "PT1H3M42S",
"totalTravelDistanceMeters": 150664,
"travelDistanceFromStartLocationToFirstVisitMeters": 8843,
"travelDistanceBetweenVisitsMeters": 69591,
"travelDistanceFromLastVisitToEndLocationMeters": 72230,
"endLocationArrivalTime": "2027-02-01T15:36:47Z"
}
}
]
}
]
},
"kpis": {
"totalTravelTime": "PT4H26M21S",
"travelTimeFromStartLocationToFirstVisit": "PT28M53S",
"travelTimeBetweenVisits": "PT2H25M58S",
"travelTimeFromLastVisitToEndLocation": "PT1H31M30S",
"totalTravelDistanceMeters": 281247,
"travelDistanceFromStartLocationToFirstVisitMeters": 23876,
"travelDistanceBetweenVisitsMeters": 155921,
"travelDistanceFromLastVisitToEndLocationMeters": 101450,
"totalUnassignedVisits": 0
}
}
modelOutput
contains Ann’s and Beth’s updated itineraries. Ann is still assigned to Visit E, and Beth is assigned to Visit M.
1.3. Finer control over pinning with freezeTime
In the above examples, setting the freezeTime
meant that the following visits were pinned:
-
All visits that had already been completed or started.
-
All visits that technicians were already traveling to.
While pinning the visits that technicians were already traveling to is a safe option in terms of not disrupting the technicians' schedules, it may not always be necessary.
Imagine a scenario where Ann just finished Visit A at 09:00 and her next visit (Visit B) starts at 14:00. It will take Ann 1 hour to travel to Visit B, so she has plenty of time.
If the planning update happens at 11:00 (freezeTime
is set to 11:00), Visit B would be pinned because Ann is already traveling to it, even though she might have enough time to handle an emergency visit at 12:00:
{
"modelInput": {
"freezeTime": "2027-02-01T11:00:00Z"
}
}
In order to avoid automatic pinning of visits that technicians are already traveling to, you can set the pinNextVisitDuringFreeze
property to NEVER
.
This property can be set globally in the modelInput
section of the route plan input, or for a specific vehicle shift (if both are set, the setting for the vehicle shift takes precedence):
{
"modelInput": {
"freezeTime": "2027-02-01T12:00:00Z",
"pinNextVisitDuringFreeze": "ALWAYS",
...
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"pinNextVisitDuringFreeze": "NEVER",
...
}
]
}
]
}
}
With this setting, you can use Visit.pinningRequested
to pin specific visits that must not be rescheduled according to your needs:
{
"modelInput": {
"freezeTime": "2027-02-01T12:00:00Z",
"pinNextVisitDuringFreeze": "NEVER",
...
"visits": [
{
"id": "Visit B",
...
"minStartTravelTime": "2027-02-01T00:00:00Z",
"pinningRequested": true
},
...
]
}
}
To summarize, the pinNextVisitDuringFreeze
property values have the following meaning:
-
ALWAYS
(default): All visits that are completed, started or that technicians might already be traveling to before thefreezeTime
are pinned. -
NEVER
: All visits that are completed or started before thefreezeTime
are pinned.
2. Pinning without freezeTime
In some cases, even batch planning may need to take into account existing visit assignments (pinned visits) without considering all parts of the plan as a history that cannot be modified.
For instance, if visits for only one technician need to be pinned, but other visits being attended by other technicians do not need to be pinned.
You can pin specific visits without setting the freezeTime
.
This will result in only the specific visits that have been pinned being frozen in the plan.
This scenario also requires that if there is a pinned visit C assigned to a vehicle shift, all visits in this vehicle shift’s itinerary before C must be pinned as well. |
For a schedule with visits A, B, C, and D assigned to the same vehicle shift, if Visit C needs to be pinned, then Visit A and Visit B must also be pinned.
3. Pinning whole vehicle shifts
In some cases, it may be useful to pin the whole vehicle shift itinerary, for instance, if the vehicle shift already ended (imagine a multi-day schedule).
Field service routing will automatically pin all vehicle shifts that have maxEndTime
or maxLastVisitDepartureTime
before or equal to the freezeTime
.
Alternatively, pinning the whole vehicle shift can be used as a convenient shortcut to pin all visits in the vehicle shift itinerary. Please be aware that pinning the whole vehicle shift means also that no new visits can be added to the vehicle shift itinerary (as opposed to pinning just the individual visits).
You can pin the whole vehicle shift by setting pinned
to true
for the vehicle shift:
{
"modelInput": {
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"pinned": true,
...
}
]
}
]
}
}
Vehicle shift pinning can be combined with visit pinning and pinning by freezeTime
.
Next
-
See the full API spec or try the online API.
-
Learn more about field service routing from our YouTube playlist.
-
Use Time windows to specify visit availability and limit when visits can be scheduled.
-
Learn about real-time planning.
-
Real-time planning with extended visits.
-
Real-time planning and emergency visits.