Real-time planning: minimizing disruption on replanning
Real-time planning can be disruptive to technician’s schedules.
Real-time planning: pinning visits solves this by hard-pinning specific visits or blocks of the schedule so that they cannot be touched at all. This is the right tool when a visit absolutely must not move, for instance because a technician has already started traveling to it.
However, pinning is an all-or-nothing tool. For every other already-planned visit that is not pinned, Timefold is completely free to move it to a different vehicle or a different time, even when doing so only brings a marginal improvement to the schedule. In practice, a small optimization gain is often not worth telling a technician (or a customer) that their already-communicated appointment has changed again.
The Minimize disruption on replanning soft constraint addresses this middle ground.
Instead of hard-blocking changes, it makes Timefold reluctant to move a visit away from its previous assignment, in proportion to how disruptive that change actually is.
A visit can still be moved or delayed when there is a good enough reason for it, for example, to keep the solution feasible or because the improvement clearly outweighs the disruption.
This guide explains the constraint with the following example:
1. Original assignment and what counts as a disruption
Learn how to configure an API Key to run the examples in this guide:
In the examples, replace |
For every visit that is already assigned when a solve starts, Timefold records its current vehicle shift, its position in that shift’s itinerary, and its startServiceTime.
This is called the visit’s original assignment for that solve.
Visits that have no original assignment, for instance a brand new emergency visit that has never been planned before, are entirely unaffected by this constraint: there is nothing for them to be disrupted from.
Once solving has produced a new plan, the Minimize disruption on replanning constraint compares each visit’s new assignment against its original assignment:
-
A change of vehicle shift is penalized with a flat penalty of
100. -
A change of vehicle is penalized with a flat penalty of
100. In the case of a visit that is moved to a different vehicle’s shift, both penalties apply, for a total of200. -
A change of
startServiceTimeis penalized in proportion to the difference, one point per second between the original and the newstartServiceTime.
These penalties are combined and multiplied by minimizeDisruptionOnReplanningWeight.
|
Every soft constraint has a weight that can be configured to change the relative importance of the constraint compared to other constraints. Learn about constraint weights. |
When this constraint is violated, its constraint match justification includes the visit’s original vehicle shift ID, its original itinerary position, and its original startServiceTime, as well as the visit’s current vehicle shift ID, itinerary position and startServiceTime, so you can see exactly what changed.
|
1.1. Batch schedule
The original schedule was generated from the following input dataset during the regular batch scheduling. Visit E prefers Beth, but at this point Beth only has a short one-hour shift late in the day, which is too short to fit Visit E’s 90-minute service. The visit is assigned to Ann instead:
-
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: minimize disruption 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-01T16:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z"
}
]
}
],
"visits": [
{
"id": "Visit E",
"location": [33.90719, -84.28149],
"serviceDuration": "PT1H30M",
"preferredVehicles": ["Beth"]
}
]
}
}
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",
"originId": "ID",
"name": "Original shift plan: minimize disruption example",
"submitDateTime": "2026-08-12T16:17:17.796942793+02:00",
"startDateTime": "2026-08-12T16:17:17.959065606+02:00",
"activeDateTime": "2026-08-12T16:17:17.959860319+02:00",
"completeDateTime": "2026-08-12T16:17:48.025174188+02:00",
"shutdownDateTime": "2026-08-12T16:17:48.025189344+02:00",
"solverStatus": "SOLVING_COMPLETED",
"score": "0hard/0medium/-3964soft",
"validationResult": {
"summary": "OK"
}
},
"modelOutput": {
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startTime": "2027-02-01T09:00:00Z",
"itinerary": [
{
"id": "Visit E",
"arrivalTime": "2027-02-01T09:31:10Z",
"startServiceTime": "2027-02-01T09:31:10Z",
"departureTime": "2027-02-01T11:01:10Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT31M10S",
"travelDistanceMetersFromPreviousStandstill": 25971,
"minStartTravelTime": "2027-02-01T00:00:00Z",
"kind": "VISIT"
}
],
"metrics": {
"totalServiceDuration": "PT1H30M",
"totalBreakDuration": "PT0S",
"totalWaitingTime": "PT0S",
"totalTravelTime": "PT1H2M20S",
"travelTimeFromStartLocationToFirstVisit": "PT31M10S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT31M10S",
"totalTravelDistanceMeters": 51942,
"travelDistanceFromStartLocationToFirstVisitMeters": 25971,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 25971,
"endLocationArrivalTime": "2027-02-01T11:32:20Z",
"overtime": "PT0S",
"availableOvertime": "PT0S"
}
}
],
"metrics": {
"activatedShifts": 1,
"assignedVisits": 1,
"totalShiftDuration": "PT2H32M20S",
"totalServiceDuration": "PT1H30M",
"totalTravelTime": "PT1H2M20S",
"totalTravelDistanceMeters": 51942,
"totalBreakTime": "PT0S",
"totalWaitingTime": "PT0S",
"totalOvertime": "PT0S",
"availableOvertime": "PT0S"
}
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startTime": "2027-02-01T16:00:00Z",
"itinerary": [],
"metrics": {
"totalServiceDuration": "PT0S",
"totalBreakDuration": "PT0S",
"totalWaitingTime": "PT0S",
"totalTravelTime": "PT0S",
"travelTimeFromStartLocationToFirstVisit": "PT0S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT0S",
"totalTravelDistanceMeters": 0,
"travelDistanceFromStartLocationToFirstVisitMeters": 0,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 0,
"overtime": "PT0S",
"availableOvertime": "PT0S"
}
}
],
"metrics": {
"activatedShifts": 0,
"assignedVisits": 0,
"totalShiftDuration": "PT0S",
"totalServiceDuration": "PT0S",
"totalTravelTime": "PT0S",
"totalTravelDistanceMeters": 0,
"totalBreakTime": "PT0S",
"totalWaitingTime": "PT0S",
"totalOvertime": "PT0S",
"availableOvertime": "PT0S"
}
}
],
"unassignedVisits": []
},
"inputMetrics": {
"vehicles": 2,
"vehicleShifts": 2,
"visits": 1,
"mandatoryVisits": 1,
"optionalVisits": 0,
"pinnedVisits": 0,
"visitsWithSla": 0,
"visitGroups": 0,
"visitDependencies": 0,
"excludedVisits": 0,
"movableVisits": 0
},
"kpis": {
"averageTravelTimePerVisit": "PT1H2M20S",
"totalTravelTime": "PT1H2M20S",
"travelTimeFromStartLocationToFirstVisit": "PT31M10S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT31M10S",
"averageTravelDistanceMetersPerVisit": 51942,
"totalTravelDistanceMeters": 51942,
"travelDistanceFromStartLocationToFirstVisitMeters": 25971,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 25971,
"totalUnassignedVisits": 0,
"totalAssignedVisits": 1,
"assignedMandatoryVisits": 1,
"unassignedMandatoryVisits": 0,
"totalActivatedVehicles": 1,
"workingTimeFairnessPercentage": 0.0,
"totalOvertime": "PT0S",
"availableOvertime": "PT0S"
}
}
modelOutput contains Ann’s and Beth’s shift itineraries.
Ann is assigned Visit E, with a startServiceTime of 09:31:10.
|
The exact |
1.2. Real-time planning update: a new shift becomes available for the preferred vehicle
A few hours later, Beth’s afternoon frees up and she is available to work a longer shift, starting well before Visit E’s original startServiceTime and comfortably long enough to fit it:
{
"id": "Beth-2027-02-01-B",
"startLocation": [33.70474, -84.06508],
"minStartTime": "2027-02-01T09:30:00Z",
"maxEndTime": "2027-02-01T17:00:00Z"
}
Without minimizeDisruptionOnReplanningWeight set, Visit E’s preference for Beth would now win: Timefold would move Visit E from Ann’s shift onto Beth’s new shift, pulling its startServiceTime forward to whenever Beth can reach it, even though nothing about Visit E itself changed and the schedule that was already communicated to Ann and the customer would need to be revised for a marginal gain.
Activate the constraint by setting minimizeDisruptionOnReplanningWeight in config.model.overrides.
This time, there is a preferred vehicle set for Visit E, so the penalty for minimizing disruptions needs to be higher than the preferred vehicle constraint’s penalty.
Let’s set the weight to 100:
{
"config": {
"model": {
"overrides": {
"minimizeDisruptionOnReplanningWeight": 100
}
}
}
}
|
Instead of resubmitting the full updated dataset, you can make this change with the Patch feature, which submits only the change as a JSON Patch document and generates a new revision of the plan. The Patch feature is currently available as a preview feature. If you’d like early access to it, please contact us. Add the new shift for Beth:
See /from-patch for more information about the Because
Because this request continues the existing dataset, Timefold automatically carries forward Ann’s and Beth’s previous assignments as the original assignment for this solve.
Unlike pinning, there is no need to add Submit the patch to generate a new revision of the plan.
|
If you are not using the Patch feature, resubmitting the full updated input dataset as a new dataset has the same effect, but because a fresh dataset has no previous solve to continue from, Visit E’s original assignment must be added back manually, the same way it would need to be for pinning individual visits, just without setting pin to true:
-
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: minimize disruption"
},
"model": {
"overrides": {
"minimizeDisruptionOnReplanningWeight": 100
}
}
},
"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",
"itinerary": [
{
"id": "Visit E",
"kind": "VISIT",
"startServiceTime": "2027-02-01T09:35:42Z"
}
]
}
]
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startLocation": [33.70474, -84.06508],
"minStartTime": "2027-02-01T16:00:00Z",
"maxEndTime": "2027-02-01T17:00:00Z"
},
{
"id": "Beth-2027-02-01-B",
"startLocation": [33.70474, -84.06508],
"minStartTime": "2027-02-01T09:30:00Z",
"maxEndTime": "2027-02-01T17:00:00Z"
}
]
}
],
"visits": [
{
"id": "Visit E",
"location": [33.90719, -84.28149],
"serviceDuration": "PT1H30M",
"preferredVehicles": ["Beth"],
"minStartTravelTime": "2027-02-01T00:00:00Z"
}
]
}
}
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",
"originId": "ID",
"name": "Real-time planning: minimize disruption",
"submitDateTime": "2026-08-17T22:42:52.813622844+02:00",
"startDateTime": "2026-08-17T22:42:53.109675321+02:00",
"activeDateTime": "2026-08-17T22:42:53.111022788+02:00",
"completeDateTime": "2026-08-17T22:43:23.20204323+02:00",
"shutdownDateTime": "2026-08-17T22:43:23.202066006+02:00",
"solverStatus": "SOLVING_COMPLETED",
"score": "0hard/0medium/-3964soft",
"validationResult": {
"summary": "OK"
}
},
"modelOutput": {
"vehicles": [
{
"id": "Ann",
"shifts": [
{
"id": "Ann-2027-02-01",
"startTime": "2027-02-01T09:00:00Z",
"itinerary": [
{
"id": "Visit E",
"arrivalTime": "2027-02-01T09:31:10Z",
"startServiceTime": "2027-02-01T09:31:10Z",
"departureTime": "2027-02-01T11:01:10Z",
"effectiveServiceDuration": "PT1H30M",
"travelTimeFromPreviousStandstill": "PT31M10S",
"travelDistanceMetersFromPreviousStandstill": 25971,
"minStartTravelTime": "2027-02-01T00:00:00Z",
"kind": "VISIT"
}
],
"metrics": {
"totalServiceDuration": "PT1H30M",
"totalBreakDuration": "PT0S",
"totalWaitingTime": "PT0S",
"totalTravelTime": "PT1H2M20S",
"travelTimeFromStartLocationToFirstVisit": "PT31M10S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT31M10S",
"totalTravelDistanceMeters": 51942,
"travelDistanceFromStartLocationToFirstVisitMeters": 25971,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 25971,
"endLocationArrivalTime": "2027-02-01T11:32:20Z",
"overtime": "PT0S",
"availableOvertime": "PT0S"
}
}
],
"metrics": {
"activatedShifts": 1,
"assignedVisits": 1,
"totalShiftDuration": "PT2H32M20S",
"totalServiceDuration": "PT1H30M",
"totalTravelTime": "PT1H2M20S",
"totalTravelDistanceMeters": 51942,
"totalBreakTime": "PT0S",
"totalWaitingTime": "PT0S",
"totalOvertime": "PT0S",
"availableOvertime": "PT0S"
}
},
{
"id": "Beth",
"shifts": [
{
"id": "Beth-2027-02-01",
"startTime": "2027-02-01T16:00:00Z",
"itinerary": [],
"metrics": {
"totalServiceDuration": "PT0S",
"totalBreakDuration": "PT0S",
"totalWaitingTime": "PT0S",
"totalTravelTime": "PT0S",
"travelTimeFromStartLocationToFirstVisit": "PT0S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT0S",
"totalTravelDistanceMeters": 0,
"travelDistanceFromStartLocationToFirstVisitMeters": 0,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 0,
"overtime": "PT0S",
"availableOvertime": "PT0S"
}
},
{
"id": "Beth-2027-02-01-B",
"startTime": "2027-02-01T09:30:00Z",
"itinerary": [],
"metrics": {
"totalServiceDuration": "PT0S",
"totalBreakDuration": "PT0S",
"totalWaitingTime": "PT0S",
"totalTravelTime": "PT0S",
"travelTimeFromStartLocationToFirstVisit": "PT0S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT0S",
"totalTravelDistanceMeters": 0,
"travelDistanceFromStartLocationToFirstVisitMeters": 0,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 0,
"overtime": "PT0S",
"availableOvertime": "PT0S"
}
}
],
"metrics": {
"activatedShifts": 0,
"assignedVisits": 0,
"totalShiftDuration": "PT0S",
"totalServiceDuration": "PT0S",
"totalTravelTime": "PT0S",
"totalTravelDistanceMeters": 0,
"totalBreakTime": "PT0S",
"totalWaitingTime": "PT0S",
"totalOvertime": "PT0S",
"availableOvertime": "PT0S"
}
}
],
"unassignedVisits": []
},
"inputMetrics": {
"vehicles": 2,
"vehicleShifts": 3,
"visits": 1,
"mandatoryVisits": 1,
"optionalVisits": 0,
"pinnedVisits": 0,
"visitsWithSla": 0,
"visitGroups": 0,
"visitDependencies": 0,
"excludedVisits": 0,
"movableVisits": 0
},
"kpis": {
"averageTravelTimePerVisit": "PT1H2M20S",
"totalTravelTime": "PT1H2M20S",
"travelTimeFromStartLocationToFirstVisit": "PT31M10S",
"travelTimeBetweenVisits": "PT0S",
"travelTimeFromLastVisitToEndLocation": "PT31M10S",
"averageTravelDistanceMetersPerVisit": 51942,
"totalTravelDistanceMeters": 51942,
"travelDistanceFromStartLocationToFirstVisitMeters": 25971,
"travelDistanceBetweenVisitsMeters": 0,
"travelDistanceFromLastVisitToEndLocationMeters": 25971,
"totalUnassignedVisits": 0,
"totalAssignedVisits": 1,
"assignedMandatoryVisits": 1,
"unassignedMandatoryVisits": 0,
"totalActivatedVehicles": 1,
"workingTimeFairnessPercentage": 0.0,
"totalOvertime": "PT0S",
"availableOvertime": "PT0S"
}
}
modelOutput shows the same result: Visit E stays with Ann at its original startServiceTime.
Next
-
See the full API spec or try the online API.
-
Learn more about field service routing from our YouTube playlist.
-
Learn about real-time planning.
-
Real-time planning with pinned visits.
-
Learn about constraints and constraint weights.