● How to use · Routing & Logistics
Pickup & delivery (PDPTW)
Each request is a pickup and a drop-off served by the same vehicle, pickup first, without ever exceeding capacity in between.
What it decides
For every request, the solver picks one vehicle and the positions of its pickup and its dropoff on that route, or leaves the whole request unrouted.
Minimal example
from optimaru.pickup_delivery.models import (
DeliveryModel, PdpInput, PdpRequest, StopModel, VehicleModel, solve_request,
)
request = PdpRequest(
timelimit=2,
random_seed=42,
model_input=PdpInput(
vehicles=[
VehicleModel(id="V1", capacity=10, home=(37.5665, 126.9780), departure_time=8 * 3600),
],
deliveries=[
DeliveryModel(
id="R1", demand=6,
pickup=StopModel(id="R1-P", location=(37.5700, 126.9820), service_duration=300),
dropoff=StopModel(id="R1-D", location=(37.5512, 126.9882), service_duration=300),
),
DeliveryModel(
id="R2", demand=6,
pickup=StopModel(id="R2-P", location=(37.5133, 127.1001), service_duration=300),
dropoff=StopModel(id="R2-D", location=(37.5219, 127.0411), service_duration=300),
),
],
),
)
solution = solve_request(request)
print(solution.feasible, solution.score)
for vehicle_id, stops in solution.routes.items():
print(vehicle_id, [(stop.stop_id, stop.pickup, stop.arrival_time) for stop in stops])
print(solution.unassigned, solution.vehicle_metrics["V1"].peak_load) The same use case as a Jupyter notebook, with charts and saved outputs: examples/pickup-delivery.ipynb ↗
Input parameters
VehicleModel
| Field | Meaning |
|---|
id required capacity required home required (latitude, longitude).departure_ time return_ to_ depot max_ stops max_ travel_ time max_ route_ duration max_ end_ time max_ route_ radius_ meters home, in straight-line meters. 0 means no limit.min_ load_ ratio capacity.fixed_ cost initial_ route pinned_ stops initial_route cannot change.StopModel
| Field | Meaning |
|---|
id required location required (latitude, longitude).min_ start_ time max_ end_ time service_ duration DeliveryModel
| Field | Meaning |
|---|
id required demand required pickup required StopModel.dropoff required StopModel.required_ vehicle order_ time squared_order_age_cost reads it.PdpInput
| Field | Meaning |
|---|
vehicles required deliveries required driving_ seconds PdpRequest
| Field | Meaning |
|---|
model_ input required PdpInput.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 (53)
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.0 | Share of best-improvement descent. 0 turns it off. |
driving_ | True | Bias move partners and repair positions toward stops close in driving time. |
driving_ | 0.8 | Probability of using that bias. |
driving_ | 20 | Neighbours kept per stop. |
first_ | True | Place the tightest windows and largest demands first. False keeps input order. |
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. |
late_ | 0 | Late-acceptance history length. 0 picks it from the instance size. |
list_ | 0.1 | 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.3 | Relative share of Or-opt moves, which relocate a short segment. 0 disables them. |
pair_ | 0.1 | Share of pair moves that drop a request or pull one back. |
pair_ | True | Build the start one complete request at a time. |
pair_ | 300 | Smallest instance, in stops, that uses them. 0 forces them at every size. |
pair_ | True | Enable moves that exchange whole requests. |
pair_ | True | Enable moves that treat a pickup and its dropoff as one. |
pair_ | 0.7 | Share of pair moves spent repairing a broken request. |
pair_ | True | Ruin whole requests, not single stops. |
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.15 | Relative share of relocate moves. 0 disables them. |
route_ | 12 | Largest route that move empties. |
route_ | 0.0 | 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_ | True | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 12 | Most entities that small ruin removes. |
ruin_ | 0.0 | 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.4 | 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_ | [3.0, 2.0, 1.0, 0.5] | 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.35 | 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 PdpSolution. The decisions are in routes and unassigned.
PdpSolution
| Field | Meaning |
|---|
routes required stop_id, delivery_id, pickup and arrival_time.unassigned score required feasible required total_ driving_ seconds required vehicle_ metrics peak_load, delivered demand, times and route end. Reported only, never scored.Constraints
Pick them by calling their classes from optimaru.pickup_delivery.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.pickup_delivery.constraints import (
PairedRequestCost, PickupBeforeDropoffCost, SameVehicleCost, TravelTimeCost,
UnassignedRequestsCost, VehicleCapacityCost, get_constraints,
)
picked = get_constraints([
PairedRequestCost(), SameVehicleCost(), PickupBeforeDropoffCost(), UnassignedRequestsCost(),
VehicleCapacityCost(), TravelTimeCost(weight=10),
])
print(solve_request(request, constraints=picked).score) | Name | Level | Default weight | What it does |
|---|---|---|---|
vehicle_ | Hard | 1 | Running load above capacity. Load rises at a pickup and falls at its dropoff, so what must fit is the peak, not the route total. |
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 | Stops above max_stops; a request counts as two. |
max_ | Hard | 1 | Meters by which the farthest stop is beyond max_route_radius_meters from home. |
enclosing_ | Soft | 0 | Radius of the smallest circle around a route's stops, home excluded. Weight 0 by default, so give it a weight to use it. |
min_ | Soft | 1 | Shortfall of a used vehicle's delivered demand below the min_load_ratio target. |
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 stop. Weight 0 by default, so give it a weight to use it. |
squared_ | Soft | 0 | Squared seconds from each request's order_time to the end of its dropoff. Weight 0 by default, so give it a weight to use it. |
vehicle_ | Soft | 1 | fixed_cost of every vehicle used at least once. |
pinned_ | Hard | 1e12 | A route must start with its vehicle's pinned stops, in order. Each misplaced stop counts once. |
required_ | Hard | 1000 | A request off its required_vehicle: once when unassigned, twice on another vehicle, so a request with no place on its vehicle waits. |
paired_ | Hard | 1000 | A request with only one of its pickup and dropoff routed. |
same_ | Hard | 1000 | A request whose pickup and dropoff ride on different vehicles. |
pickup_ | Hard | 1000 | A request whose dropoff comes before its pickup on the route. |
unassigned_ | Medium | 1 | Service minutes of requests with neither stop routed. |
Starting from an existing solution
This needs the latest release: pip install -U optimaru.
with_initial_solution(request, solution) turns a solution into the initial_route of each vehicle, replacing the ones the request had.
This continues the minimal example above.
from optimaru.pickup_delivery.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 requests it leaves out are constructed.
- Requests 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. - A vehicle's
pinned_stopsstay as the request has them: its route in the solution must start with them, orValueErroris raised. - An unknown id, a stop routed twice, or a request whose pickup and dropoff are apart also raises
ValueError, so a hard-infeasible result with a broken pair cannot be used as a start.
Exceptions and limits
max_stopsis 20 onVehicleModelunless you change it. Pass 0 to lift the cap.- Routes are open by default: without
return_to_depot, the leg back home is neither driven nor timed. - Goods already on board have no pickup left. Give them a pickup at the vehicle's position with no service time, and pin it with
pinned_stops. - Pinning a pickup keeps its request on the vehicle. Stops after the pinned ones can be reordered, moved to another vehicle or have stops inserted between.
required_vehiclekeeps a request on a vehicle without fixing its place, for work that is promised but can still be reordered.- 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.