Skip to content

●  How to use · Routing & Logistics

Order picking

Batch pick tasks onto trolleys and sequence them through the aisles so pickers walk less and trolleys stay within volume.

What it decides

For every item of every order, the solver picks a trolley and a position in that trolley's walking sequence.

Minimal example

from optimaru.order_picking.models import (
    LocationModel, OrderModel, OrderPickingRequest, ProductModel, TrolleyModel, solve_request,
)

def shelf(column, shelving_row, side, row):
    return LocationModel(column=column, shelving_row=shelving_row, side=side, row=row)

request = OrderPickingRequest(
    timelimit=2,
    random_seed=42,
    trolleys=[
        TrolleyModel(id="T1", bucket_count=2, bucket_capacity=48000, location=shelf("A", 1, "LEFT", 0)),
    ],
    orders=[
        OrderModel(id="O1", items=[
            ProductModel(id="milk", volume=1232, location=shelf("D", 1, "RIGHT", 4)),
            ProductModel(id="rice", volume=9000, location=shelf("B", 2, "LEFT", 7)),
        ]),
        OrderModel(id="O2", items=[
            ProductModel(id="soap", volume=800, location=shelf("B", 2, "RIGHT", 3)),
        ]),
    ],
)

solution = solve_request(request)
print(solution.feasible, solution.score, solution.total_distance)
for trolley_id, stops in solution.routes.items():
    print(trolley_id, [stop.pick_task_id for stop in stops])
print(solution.unassigned, solution.order_split_count)

The same use case as a Jupyter notebook, with charts and saved outputs: examples/order-picking.ipynb ↗

Input parameters

LocationModel

Field Meaning
column required Shelving column, A to E. shelving_row required Shelving row, 1 to 3. side required LEFT or RIGHT. row required Row inside the shelving, 0 to 10.

ProductModel

Field Meaning
id required Product id. volume required Volume in cm³. location required Where to pick it.

OrderModel

Field Meaning
id required Order id. items required The items to pick. Their position forms the pick task id.

TrolleyModel

Field Meaning
id required Trolley id. bucket_count required Buckets on the trolley. bucket_capacity required Volume of one bucket in cm³. location required Where the trolley starts. initial_route Pick task ids the route starts from.

OrderPickingRequest

Field Meaning
trolleys required The trolleys. orders required The orders.

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_seed 0 Seed for the search.
num_workers 1 Independent parallel starts. The best result wins.
stop_at_first_feasible False Stop as soon as hard 0 is reached.
solver_parameters {} 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 (27)

Pass these by name in solver_parameters.

Parameter Default Meaning
best_score_limit — Stop early once the best score reaches this value. By default it never fires.
distance_nearby True Draw move partners from the physically closest picks.
distance_nearby_probability 0.5 Probability of doing so.
distance_nearby_size 24 Neighbours kept per pick.
full_assert False Check every incremental score against a full recompute. Slow; for debugging.
late_acceptance_size 0 Late-acceptance history length. 0 picks it from the instance size.
list_swap_move_weight 0.25 Relative share of list swap moves. 0 disables them.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
or_opt_move_weight 0.1 Relative share of Or-opt moves, which relocate a short segment. 0 disables them.
order_consolidate_moves True Enable moves that gather a split order onto one trolley.
parallel_solver_count 1 Independent parallel starts, each with its own seed and the full budget. The best one wins.
random_seed 0 Seed for the search.
relocate_move_weight 0.4 Relative share of relocate moves. 0 disables them.
ruin_recreate True Run adaptive ruin-and-recreate kicks when the search stagnates.
ruin_recreate_max_ruin_count 60 Absolute cap on entities removed per kick.
ruin_recreate_max_ruin_fraction 0.3 Largest share of the solution one kick removes.
ruin_recreate_min_ruin_fraction 0.1 Smallest share of the solution one kick removes.
ruin_recreate_operator_decay 0.8 How fast operator weights adapt.
ruin_recreate_polish_steps 5000 Hill-climbing moves that polish each kick. Not counted against step_count_limit.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] Operator rewards for a new best, a better, an accepted and a rejected result.
ruin_recreate_unimproved_step_trigger 50000 Steps without a new best before a kick.
spent_limit_seconds 30.0 Time budget in seconds, from 0 to 1e9.
step_count_limit 0 Stop after this many steps. 0 means no limit.
tail_swap_move_weight 0.1 Relative share of tail swaps (2-opt*) between two lists. 0 disables them.
two_opt_move_weight 0.15 Relative share of 2-opt moves. 0 disables them.
unimproved_spent_limit_seconds 0.0 Stop after this many seconds without a new best. 0 means no limit.
unimproved_step_count_limit 0 Stop after this many steps without a new best. 0 means no limit.

What comes back

solve_request returns an OrderPickingResponse. The decisions are in routes.

OrderPickingResponse

Field Meaning
routes required Trolley id to its ordered stops. Each stop has pick_task_id, order_id, product_name, volume and location. unassigned Pick task ids on no trolley. distance_by_trolley required Meters per trolley, including the return leg. total_distance required Meters across all trolleys. order_split_count required Sum, over orders, of the trolleys each order spans. score required Score as text, with hard and soft levels. feasible required True when no hard constraint is broken.

Constraints

Pick them by calling their classes from optimaru.order_picking.constraints, such as TrolleyCapacityCost(). 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" makes the constraint a must and "soft" a preference, as do Level.HARD and Level.SOFT. Left unset, it keeps the level below. This use case has no medium level, so "medium" raises ValueError. A reward refuses "hard", and a fixed level refuses any change; both raise ValueError.
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.

This continues the minimal example above.

from optimaru.order_picking.constraints import (
    DistanceFromPreviousPickTaskCost, OrderSplitByTrolleyCost, TrolleyCapacityCost,
    get_constraints,
)

picked = get_constraints([
    TrolleyCapacityCost(),
    DistanceFromPreviousPickTaskCost(),
    OrderSplitByTrolleyCost(weight=5000),
])
print(solve_request(request, constraints=picked).score)
Name Level Default weight What it does
trolley_capacity_cost Hard 1 Buckets a trolley is short of. Orders never share a bucket, so each order rounds up on its own.
distance_from_previous_pick_task_cost Soft 1 Distance from each pick to the next. For the first pick, the trolley's start stands in.
distance_from_last_pick_task_to_path_origin_cost Soft 1 Distance from the last pick back to the start. An empty trolley costs nothing.
order_split_by_trolley_cost Soft 1000 Number of (order, trolley) pairs, so an order on three trolleys costs three times one on a single trolley.

How constraints work →

Starting from an existing solution

This needs the latest release: pip install -U optimaru.

with_initial_solution(request, solution) returns a new request whose trolleys carry initial_route, a list of pick task ids.

This continues the minimal example above.

from optimaru.order_picking.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.total_distance)
  • A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the pick tasks it leaves out are constructed.
  • With timelimit=0 the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this: step_count_limit = 0 means no limit.
  • An unknown id or a task routed twice raises ValueError.
  • A pick task id is <order id>-<item position>, so keep each order's items in the order they were solved in. Moved, the same id names a different item.

Exceptions and limits

  • The warehouse is a fixed grid: columns A to E, shelving rows 1 to 3, two sides, and rows 0 to 10 in a shelving.
  • Orders never share a bucket. A trolley needs the sum, over its orders, of each order's volume divided by bucket_capacity, rounded up.