● How to use · Routing & Logistics
Vehicle routing (VRP)
Assign visits to vehicles and order them into routes, respecting capacities and time windows while minimizing travel.
What it decides
For every visit, the solver picks a vehicle and a position on that vehicle's route. A visit no route can take stays unassigned and costs a medium penalty.
Minimal example
from optimaru.vehicle_routing.models import (
RoutePlanRequest, RoutingInput, VehicleModel, VisitModel, solve_request,
)
request = RoutePlanRequest(
timelimit=2,
random_seed=42,
model_input=RoutingInput(
vehicles=[
VehicleModel(id="V1", capacity=6, depot=(37.5665, 126.9780), departure_time=8 * 3600),
VehicleModel(id="V2", capacity=6, depot=(37.5665, 126.9780), departure_time=8 * 3600),
],
visits=[
VisitModel(id="A", location=(37.5700, 126.9820), demand=3, service_duration=600,
min_start_time=9 * 3600, max_end_time=12 * 3600),
VisitModel(id="B", location=(37.5512, 126.9882), demand=3, service_duration=600,
min_start_time=9 * 3600, max_end_time=17 * 3600),
VisitModel(id="C", location=(37.5133, 127.1001), demand=4, service_duration=600),
],
),
)
solution = solve_request(request)
print(solution.feasible, solution.score, solution.total_driving_seconds)
for vehicle_id, stops in solution.routes.items():
print(vehicle_id, [(stop.visit_id, stop.arrival_time) for stop in stops])
print(solution.unassigned) The same use case as a Jupyter notebook, with charts and saved outputs: examples/vehicle-routing.ipynb ↗
Input parameters
VehicleModel
| Field | Meaning |
|---|
id required capacity required depot required (latitude, longitude).departure_ time return_ to_ depot depot. Off by default, so a route ends at its last stop.max_ travel_ time max_ route_ duration max_ end_ time max_ stops max_ route_ radius_ meters depot, in straight-line meters. 0 means no limit.min_ load_ ratio fixed_ cost initial_ route VisitModel
| Field | Meaning |
|---|
id required location required (latitude, longitude).demand min_ start_ time max_ end_ time service_ duration unassigned_ penalty_ minutes RoutingInput
| Field | Meaning |
|---|
vehicles required visits required driving_ seconds RoutePlanRequest
| Field | Meaning |
|---|
model_ input required RoutingInput.Solve parameters
What you pass to the solve call. Time limit, seed and parallel count are here.
| Parameter | Default | Meaning |
|---|---|---|
name | None | Optional label for the request. |
timelimit | 30.0 | Search budget in seconds, from 0 to 1e9. |
random_ | 0 | Seed for the search. |
num_ | 1 | Independent parallel starts. The best result wins. |
stop_ | False | Stop as soon as hard 0 is reached. |
solver_ | {} | SolverConfig fields by name, as a dict. It cannot set the time limit, seed, worker count or on_new_best. |
constraints | None | Which constraints to score, and how. Left unset, the defaults apply. |
All search options (58)
Pass these by name in solver_parameters.
| Parameter | Default | Meaning |
|---|---|---|
acceptor_ | LATE_ACCEPTANCE | Which rule accepts or rejects a move. |
best_ | — | Stop early once the best score reaches this value. By default it never fires. |
blink_ | 0.01 | Probability of skipping a candidate placement in repair, so repeated kicks differ. 0 disables it. |
descent_ | 4 | Improving passes before giving up on a neighbourhood. |
descent_ | 0.03 | Share of best-improvement descent. 0 turns it off. |
driving_ | True | Bias move partners and repair positions toward stops close in driving time. |
driving_ | 1.0 | Probability of using that bias. |
driving_ | 25 | Neighbours kept per stop. |
ejection_ | 4 | Candidate routes per chain step. |
ejection_ | 50000 | Score evaluations one chain may spend. |
ejection_ | 24 | Longest ejection chain. |
ejection_ | 0.05 | Share of ejection-chain moves. 0 turns them off. |
first_ | True | Place the tightest windows and largest demands first. False keeps input order. |
granular_ | True | Search nearby route pairs with best-improvement moves. |
granular_ | 4 | Candidate routes per pass. |
granular_ | 2 | Longest segment moved. |
inter_ | 3 | Longest segment a CROSS exchange swaps. |
inter_ | 0.0 | Share of CROSS-exchange moves, which swap a run of stops between two routes. Off by default. |
iterated_ | 5000000 | Evaluation budget of the descent. |
iterated_ | 8 | Most visits ruined per move. |
iterated_ | 1 | Longest segment its descent moves. |
iterated_ | 2 | Fewest visits ruined per move. |
iterated_ | 0.05 | Share of iterated local search moves. 0 disables them. |
late_ | 0 | Late-acceptance history length. 0 picks it from the instance size. |
list_ | 0.2 | Relative share of list swap moves. 0 disables them. |
on_ | None | Callback run on each new best solution. If it raises, the solve stops and raises the same exception. |
or_ | 0.15 | Relative share of Or-opt moves, which relocate a short segment. 0 disables them. |
parallel_ | 1 | Independent parallel starts, each with its own seed and the full budget. The best one wins. |
random_ | 0 | Seed for the search. |
record_ | 0.0 | The same gap at the end of the search. |
record_ | 0.02 | With record-to-record acceptance, the gap allowed below the best score at the start, as a fraction of the initial objective. |
regret_ | 3 | Regret-k repair for values of 2 or more. 0 or 1 means plain greedy repair. |
relocate_ | 0.4 | Relative share of relocate moves. 0 disables them. |
route_ | 12 | Largest route that move empties. |
route_ | 0.1 | Share of moves that empty the smallest route into the others. 0 turns them off. |
route_ | True | Add a ruin operator that empties whole routes. |
ruin_ | False | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 12 | Most entities that small ruin removes. |
ruin_ | 0.05 | Probability that a step is a small ruin and repair, judged like any other move. Negative picks it automatically where the use case supports that; 0 turns it off. |
ruin_ | 60 | Absolute cap on entities removed per kick. |
ruin_ | 0.3 | Largest share of the solution one kick removes. |
ruin_ | 0.1 | Smallest share of the solution one kick removes. |
ruin_ | 0.8 | How fast operator weights adapt. |
ruin_ | 5000 | Hill-climbing moves that polish each kick. Not counted against step_count_limit. |
ruin_ | [33.0, 9.0, 3.0, 0.0] | Operator rewards for a new best, a better, an accepted and a rejected result. |
ruin_ | 0.05 | Exploration weight for the ALPHA_UCB selection. |
ruin_ | 100 | Iterations per weight update for the segmented roulette wheel. |
ruin_ | ROULETTE_WHEEL | How ruin operators are picked. |
ruin_ | 5000 | Steps without a new best before a kick. |
sisr_ | 3 | Most strings one substring removal takes. |
sisr_ | 12 | Longest string it takes. |
sisr_ | True | Add substring removal to the ruin operators. |
spent_ | 30.0 | Time budget in seconds, from 0 to 1e9. |
step_ | 0 | Stop after this many steps. 0 means no limit. |
tail_ | 0.1 | Relative share of tail swaps (2-opt*) between two lists. 0 disables them. |
two_ | 0.15 | Relative share of 2-opt moves. 0 disables them. |
unimproved_ | 0.0 | Stop after this many seconds without a new best. 0 means no limit. |
unimproved_ | 0 | Stop after this many steps without a new best. 0 means no limit. |
What comes back
solve_request returns a RouteSolution. The decisions are in routes and unassigned.
RouteSolution
| Field | Meaning |
|---|
routes required visit_id, arrival_time and demand.unassigned score required 0hard/0medium/-1078soft.feasible required total_ driving_ seconds required vehicle_ metrics Constraints
Pick them by calling their classes from optimaru.vehicle_routing.constraints, such as VehicleCapacityCost(). Only what you pass is scored; pass nothing to keep the defaults. A list may name a constraint more than once, and each pick is scored on its own. Each one takes these parameters:
| Parameter | Default | Meaning |
|---|---|---|
weight | None | Multiplier on the constraint's cost, in units of the level in effect. Left unset, the default weight below applies. A constraint whose default weight is 0 is off until you give it a weight. |
level | None | "hard", "medium" or "soft" moves the constraint onto that level, as do Level.HARD, Level.MEDIUM and Level.SOFT. Left unset, it keeps the level below. This use case has a medium level, and any constraint may move onto it. |
exponent | 1.0 | Sign-preserving power on the cost, above 0, taken after offset. 2.0 squares it, so concentrated violations cost much more. It applies per unit, such as a vehicle or a day, or to the total of a constraint scored as one sum. |
offset | 0.0 | Subtracted from the cost x before the exponent, per unit or total like the exponent. It is in the cost's own measure: seconds, minutes, a count and so on. Any finite number of either sign, keyword only. clip decides what happens below it. |
clip | True | True makes offset a threshold: only the part beyond it counts, max(0, x - offset) ** exponent, so a cost at or below it adds nothing. False takes the literal (x - offset) ** exponent, sign kept: a unit below the offset counts negative, as a reward, even one whose cost is 0. On a level that counts toward hard, clip=False with a non-zero offset raises ValueError, since hard 0 must mean no violation. Keyword only. |
A medium constraint weighs 1 in medium units by default. The search compares solutions on medium * 1e9 + soft, so medium outranks soft while the soft total stays below 1e9. With level, any constraint moves onto any of the three levels: level="medium" puts it on medium, and "hard" or "soft" takes a medium one off it. A weight counts in units of the level in effect.
This continues the minimal example above.
from optimaru.vehicle_routing.constraints import (
MaxEndTimeCost, TravelTimeCost, UnassignedVisitsCost, VehicleCapacityCost, get_constraints,
)
picked = get_constraints([
VehicleCapacityCost(), # on, as it is
UnassignedVisitsCost(), # on, as it is
TravelTimeCost(weight=10), # reweighted
MaxEndTimeCost(level="soft"), # hard turned into soft
TravelTimeCost(level="medium", offset=7200), # picked again: driving beyond 2 h per vehicle, on medium
])
print(solve_request(request, constraints=picked).score) | Name | Level | Default weight | What it does |
|---|---|---|---|
vehicle_ | Hard | 1 | Load above the vehicle's capacity. |
max_ | Hard | 1 | Minutes, rounded up, by which arrival plus service passes max_end_time. |
travel_ | Soft | 1 | Total driving time. The leg back counts only with return_to_depot. |
max_ | Hard | 1 | Driving seconds above max_travel_time. |
max_ | Hard | 1 | Route seconds above max_route_duration, waits and service included. |
vehicle_ | Hard | 1 | Seconds by which the route ends after max_end_time. |
max_ | Hard | 1 | Visits above max_stops. |
max_ | Hard | 1 | Meters by which the farthest visit is beyond max_route_radius_meters from the depot. |
enclosing_ | Soft | 0 | Radius of the smallest circle around a route's visits, depot excluded. Weight 0 by default, so give it a weight to use it. |
min_ | Soft | 1 | Shortfall of a used vehicle below min_load_ratio. |
route_ | Soft | 0 | Total route time, waits and service included. Weight 0 by default, so give it a weight to use it. |
latency_ | Soft | 0 | Sum of the times taken to reach each visit. Weight 0 by default, so give it a weight to use it. |
vehicle_ | Soft | 1 | fixed_cost of every vehicle used at least once. |
unassigned_ | Medium | 1 | Each unrouted visit's unassigned_penalty_minutes. |
Starting from an existing solution
This needs the latest release: pip install -U optimaru.
with_initial_solution(request, solution) returns a new request whose vehicles carry initial_route, a list of visit ids. The request you pass in is not changed.
This continues the minimal example above.
from optimaru.vehicle_routing.models import with_initial_solution
seeded = with_initial_solution(request, solution) # or solution.model_dump(mode="json")
better = solve_request(seeded)
print(better.score, better.unassigned) - A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the visits it leaves out are constructed.
- Visits in
unassignedget another try. - With
timelimit=0the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this:step_count_limit = 0means no limit. - An unknown id or a visit routed twice raises
ValueError.
Exceptions and limits
- Times are integer seconds from a common day start.
- Routes are open by default: without
return_to_depot, the leg back is neither driven nor timed. - Lateness is measured from arrival plus service, so time spent waiting for a window to open does not count.
- Setting any of
max_travel_time,max_route_duration,max_end_time,max_stops,min_load_ratio,fixed_costormax_route_radius_metersruns the solve with constraint plugins, wherenum_workershas no effect.