Claude Media
配送ルート最適化をOR-ToolsとClaude Codeで作る — 積載量と時間枠の検算

配送ルート最適化をOR-ToolsとClaude Codeで作る — 積載量と時間枠の検算

拠点・配送先・積載量・時間枠をCSVに置き、OR-Toolsで配送ルートを組む。Claude Codeに書かせたあと、制約の入れ忘れを別コードで検算する手順です。

配送ルートの最適化は、Claude CodeにOR-Tools(Googleの最適化ライブラリ)のコードを書かせれば動くところまで進みます。難しいのはその先です。積載量や時間枠の制約が1つ抜けても、ソルバーはエラーを出さず、もっともらしい経路を返します。

この記事では、CSVの停留所リストから3台分のルートを組み、経路だけを使って積載量と到着時刻を別コードで再計算する構成を作ります。制約を意図的に外したとき、検算が何を検出したかも載せます。

作るもの — CSV3列の拠点と配送先から、3台のルートを出す

入力は1枚のCSVです。1行目を拠点(デポ)、2行目以降を配送先にします。

id,name,x_km,y_km,demand_kg,tw_start,tw_end,service_min
0,depot,0,0,0,0,480,0
1,A,4,3,20,30,120,10
2,B,-3,6,35,60,180,10
3,C,7,-2,25,0,90,10
4,D,-5,-4,40,120,240,15
5,E,2,9,15,90,200,10
6,F,9,5,30,150,300,10
7,G,-8,2,20,0,150,10
8,H,5,-7,45,200,360,15
9,I,-2,-8,10,60,200,10
10,J,10,-6,30,240,420,10

時間の単位は、拠点の出発可能時刻を0とした分です。tw_start と tw_end が時間枠、service_min が荷降ろしにかかる分、demand_kg が配送量です。距離は座標のユークリッド距離で、時速30kmとして移動分に直します。実際の道路で組むなら、この部分を地図サービスの所要時間に置き換えます。

車両は3台で、積載上限は1台あたり100kgとしました。配送量の合計は270kgなので、3台の上限合計300kgに対して余裕は30kgしかありません。この余裕の薄さが、あとで制約の入れ忘れを見つけやすくします。

制約をOR-Toolsの部品に対応させる

積載量と時間枠は、OR-Toolsではどちらも「ディメンション」(経路に沿って累積する量)として表します。Claude Codeに渡す前に、この対応を頭に入れておくと、生成コードのどこを見ればよいかが分かります。

条件OR-Toolsでの表し方コードで見る箇所
配送量と積載上限OR-Toolsでの表し方配送量を返すコールバックを登録し、AddDimensionWithVehicleCapacity に車両ごとの上限を渡すコードで見る箇所上限のリストが車両数と同じ長さか
時間枠OR-Toolsでの表し方AddDimension で時間のディメンションを作り、各地点の CumulVar(index).SetRange(開始, 終了) を設定コードで見る箇所拠点を除く全地点に SetRange があるか
早着の待機OR-Toolsでの表し方AddDimension の第2引数(待機の許容量)コードで見る箇所0にすると待てず、窓に合わない
荷降ろし時間OR-Toolsでの表し方時間コールバックに出発地の service_min を足すコードで見る箇所足す側が出発地か到着地か

積載量は、地点ごとの配送量を返す1引数のコールバックで表します。到着地の組を引数に取る距離のコールバックとは形が違い、配送量は地点だけで決まるためです。車両の上限が全台同じなら AddDimension も使えますが、AddDimensionWithVehicleCapacity なら車両ごとに上限を変えられます。

時間枠の例では、AddDimension の第2引数に「待機してよい時間」を渡し、第3引数に1台あたりの最大時間を渡します。これを小さくしすぎると、窓に合わせて待つ余地がなくなります。

Claude Codeに渡す指示 — CLAUDE.mdで検算の約束を固定する

実行環境を整え、プロジェクトのCLAUDE.mdに最小限の約束を書いておきます。CLAUDE.mdは毎回のセッションの冒頭で読み込まれる文脈です。強制力のある設定ではないので、守らせたいことは短く具体的に書くほうが従われやすくなります。

python3 -m venv venv
./venv/bin/pip install ortools
# 配送ルート最適化
- 入力は stops.csv(列: id,name,x_km,y_km,demand_kg,tw_start,tw_end,service_min)
- 時間の単位は分。拠点(id=0)の出発可能時刻を0とする
- 制約は「積載上限」「時間枠」「全地点を1回ずつ訪問」の3つ
- ソルバーを変更したら、必ず `./venv/bin/python vrp.py` を実行し、
  末尾の「検算: OK / NG」を貼って報告する。NG のまま完了としない
- 検算は verify() が経路だけから再計算する。ソルバーの内部値は使わない

Claude Codeの公式ガイドは、Claudeに「実行できる確認」を渡すことを勧めています。確認の手段がないと、Claudeにとっての完了は「それらしく見えること」だけになり、間違いに気づくのが人間の役目になるためです。ここでは検算の OK/NG が、その確認にあたります。

最初の依頼は、次のように書きます。

stops.csv を読み、OR-Tools で3台のルートを求める vrp.py を作ってください。
積載上限は1台100kg、時間は分単位です。移動時間は直線距離を時速30kmで
換算して切り上げ、荷降ろしの service_min は出発地側で足します。
あわせて、経路のリストだけを受け取って積載量・到着時刻・訪問漏れを
再計算する verify() を書き、実行結果の末尾に「検算: OK/NG」を出してください。

実行のたびに許可を求められるのが煩わしければ、.claude/settings.json で実行コマンドだけ許可できます。ワイルドカードの書き方は、Bash(npm run *) のように末尾に * を付けます。Bash(ls:*) のように :* を付ける書き方も、末尾のワイルドカードと同じ意味です。

{
  "permissions": {
    "allow": ["Bash(./venv/bin/python vrp.py *)"]
  }
}

テストコマンドをCLAUDE.mdに書いて回させる考え方は、Claude CodeでVitestを回すの記事と同じ型です。

完成したコード — ソルバーと検算を分ける

動かしたコードの全体は次のとおりです。ソルバー部分(solve)と検算部分(verify)は、互いの値を一切使わない作りにしています。

import csv, math, sys
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
 
SPEED_KM_PER_MIN = 0.5      # 30 km/h
VEHICLES, CAPACITY = 3, 100  # 台数, 1台あたり積載上限(kg)
 
def load(path):
    with open(path, newline="") as f:
        return [{k: (v if k == "name" else int(float(v)) if k in
                 ("id","demand_kg","tw_start","tw_end","service_min") else float(v))
                 for k, v in r.items()} for r in csv.DictReader(f)]
 
def travel(a, b):
    return math.ceil(math.hypot(a["x_km"]-b["x_km"], a["y_km"]-b["y_km"]) / SPEED_KM_PER_MIN)
 
def solve(stops, use_capacity=True, use_windows=True):
    n = len(stops)
    mgr = pywrapcp.RoutingIndexManager(n, VEHICLES, 0)
    routing = pywrapcp.RoutingModel(mgr)
 
    def time_cb(i, j):
        a, b = stops[mgr.IndexToNode(i)], stops[mgr.IndexToNode(j)]
        return travel(a, b) + a["service_min"]      # 移動 + 出発地の荷降ろし
    t = routing.RegisterTransitCallback(time_cb)
    routing.SetArcCostEvaluatorOfAllVehicles(t)
 
    if use_capacity:
        d = routing.RegisterUnaryTransitCallback(
            lambda i: stops[mgr.IndexToNode(i)]["demand_kg"])
        routing.AddDimensionWithVehicleCapacity(d, 0, [CAPACITY]*VEHICLES, True, "Load")
 
    routing.AddDimension(t, 60, 480, False, "Time")   # 待機60分まで・1台480分まで
    td = routing.GetDimensionOrDie("Time")
    if use_windows:
        for s in stops[1:]:
            td.CumulVar(mgr.NodeToIndex(s["id"])).SetRange(s["tw_start"], s["tw_end"])
        for v in range(VEHICLES):
            td.CumulVar(routing.Start(v)).SetRange(stops[0]["tw_start"], stops[0]["tw_end"])
 
    p = pywrapcp.DefaultRoutingSearchParameters()
    p.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
    p.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
    p.time_limit.FromSeconds(2)
    sol = routing.SolveWithParameters(p)
    if not sol:
        return None
    routes = []
    for v in range(VEHICLES):
        i, r = routing.Start(v), []
        while not routing.IsEnd(i):
            r.append(mgr.IndexToNode(i)); i = sol.Value(routing.NextVar(i))
        r.append(0)
        routes.append(r)
    return routes
 
def verify(stops, routes):
    """ソルバーの内部値に頼らず、経路だけから積載量・到着時刻を再計算する"""
    errors, seen = [], []
    for v, r in enumerate(routes):
        if len(r) == 2: continue
        clock, load = stops[0]["tw_start"], 0
        for a, b in zip(r, r[1:]):
            clock += travel(stops[a], stops[b]) + stops[a]["service_min"]
            if b == 0: break
            clock = max(clock, stops[b]["tw_start"])      # 早着は窓の開始まで待つ
            load += stops[b]["demand_kg"]; seen.append(b)
            if clock > stops[b]["tw_end"]:
                errors.append(f"車両{v}: 地点{b}に{clock}分着(窓 {stops[b]['tw_start']}-{stops[b]['tw_end']})")
        if load > CAPACITY:
            errors.append(f"車両{v}: 積載{load}kg > 上限{CAPACITY}kg")
    miss = sorted(set(range(1, len(stops))) - set(seen))
    dup = sorted({x for x in seen if seen.count(x) > 1})
    if miss: errors.append(f"未訪問: {miss}")
    if dup: errors.append(f"重複訪問: {dup}")
    return errors
 
if __name__ == "__main__":
    stops = load("stops.csv")
    flags = set(sys.argv[1:])
    routes = solve(stops, "--no-capacity" not in flags, "--no-windows" not in flags)
    if routes is None:
        print("解なし"); sys.exit(1)
    for v, r in enumerate(routes):
        print(f"車両{v}: " + " -> ".join(map(str, r)))
    errs = verify(stops, routes)
    print("検算: OK" if not errs else "検算: NG\n  " + "\n  ".join(errs))

solve の中で要になるのは、次の3か所です。

  • 時間コールバックは「移動分 + 出発地の荷降ろし分」を返す
  • AddDimensionWithVehicleCapacity に [CAPACITY]*VEHICLES を渡す
  • 拠点を除く全地点と各車両の開始点に SetRange で窓を設定する

解の探索は、1秒の制限を2秒に変えた以外、ドキュメントの例と同じ設定です。初期解は PATH_CHEAPEST_ARC、改善は GUIDED_LOCAL_SEARCH を使っています。

検算の設計 — ソルバーが「解けた」と言っても信用しない

verify は経路を先頭から順にたどり、次を数えます。

  1. 移動時間と荷降ろし時間を足して時計を進める
  2. 窓の開始より早く着いたら、開始まで待つ
  3. 窓の終了を過ぎたら違反として記録する
  4. 車両ごとの積載の合計が上限を超えたら違反として記録する
  5. 全地点が1回ずつ現れているか、集合で確かめる

ここで大事なのは、ソルバーの内部変数を読まないことです。ソルバーのモデルに入れ忘れた制約は、そのモデルの値を読んでも検出できません。経路という出力だけから別の実装で計算し直すから、入れ忘れが見えます。

この条件で実行した結果は次のとおりです。環境はPython 3.12.2、OR-Tools 9.15.6755でした。

$ ./venv/bin/python vrp.py
車両0: 0 -> 1 -> 3 -> 6 -> 5 -> 0
車両1: 0 -> 2 -> 7 -> 4 -> 0
車両2: 0 -> 9 -> 8 -> 10 -> 0
検算: OK

車両0の積載は20 + 25 + 30 + 15 = 90kg、車両1は35 + 20 + 40 = 95kg、車両2は10 + 45 + 30 = 85kgで、どれも100kg以内です。

制約を外すと何が起きるか — 検算が拾った3つの結果

同じ検算を、制約を意図的に外した2通りの実行にも当てました。コマンドに --no-capacity と --no-windows を付けると、それぞれ積載上限と時間枠をモデルに入れません。

実行ソルバーの出力検算の結果
両方の制約ありソルバーの出力3台に分散検算の結果OK
--no-capacityソルバーの出力車両0に10地点すべて、残り2台は空検算の結果車両0の積載が270kgで上限100kgを超過
--no-windowsソルバーの出力3台に分散検算の結果地点3に260分着(窓は0〜90)、地点2に199分着(窓は60〜180)

積載上限を外した実行では、ソルバーは何のエラーも出さず、1台に全部載せた経路を返しました。移動距離だけを見れば自然な答えです。時間枠を外した実行は、3台に分かれているぶん、見た目では異常に気づきにくい結果でした。窓の違反は経路の形からは読み取れず、到着時刻を計算して初めて分かります。

「制約を入れ忘れる」とは、Claude Codeが書くコードでも人間が書くコードでも起きます。生成されたコードを目で追うより、制約を外した版で検算がNGになることを一度確かめておくほうが、検算そのものの信頼性を示せます。検算がNGを返せない検算は、役に立ちません。

解が出ないとき — 積載の合計が足りない場合

上限を85kgに下げると、配送量の合計270kgが3台の合計255kgを超えます。この場合、2秒の制限のなかで solve は None を返し、スクリプトは「解なし」と出力して終了しました。

OR-Toolsのドキュメントは、容量を超える問題では探索が長引くことがあると説明し、対策として2つを挙げています。1つは探索の時間制限を設けること、もう1つは訪問を落とすペナルティを設定して、全地点を回れない解も許すことです。ただし、時間制限だけでは、探索に時間がかかるだけの解のある問題でも打ち切られることがあります。

ペナルティを使う場合は、検算の「全地点を1回ずつ訪問」を外すか、落とした地点を別表で出す必要があります。どの荷物を積み残したかを確認しないまま使うと、配送漏れに気づけません。

条件を足すとき — 荷の種類と最長ルートの偏り

条件を増やしても、考え方は同じです。荷の種類ごとに上限がある場合は、種類ごとに配送量のコールバックとディメンションを作り、名前を分けます。ドキュメントは燃料配送車を例に、タンクごとに容量が違う問題をこの方法で扱うと説明しています。

台数間の偏りを減らしたいときは、距離のディメンションに SetGlobalSpanCostCoefficient で大きな係数を与えます。ドキュメントの例では係数を100にして、もっとも長いルートを短くする方向へ目的関数を寄せています。この記事のコードは総移動時間を最小にするだけなので、偏りは制御していません。

時間枠つきの解には「解の窓」という考え方もあります。各地点に到着してよい時間帯のうち、スケジュールを守るために実際に必要な範囲を指します。窓の幅が1点に絞られた地点は、到着したらすぐ出発しないと後ろの配送先に間に合いません。

つまずきやすい箇所

  • 窓の単位が分なのに、コードの一部が時間(時)になっていないか。CSVの列とコールバックの単位を、CLAUDE.mdで固定しておきます
  • 荷降ろし時間を到着地側に足すと、最後の地点から拠点に戻る分の扱いが変わります。出発地側に足すと決めて、依頼文にも書いておきます
  • 拠点にも時間枠があり、各車両の開始点にも SetRange が必要です。拠点を飛ばす実装だと、車両の出発時刻が制約されません
  • 待機の許容量が0だと、早着しても待てません。窓の開始が遅い配送先が多いときは、この引数を確かめます
  • 座標からの直線距離は、道路の距離とは別物です。ドキュメントの例も、設定の手軽さのためにマンハッタン距離を使っており、問題に合う方法を選んでよいと書いています

実際の道路距離が必要なときは、Google Distance Matrix APIで距離行列を作る例がOR-Toolsのドキュメントにあります。1回のリクエストで受け付ける要素数は100までなので、行を分けて問い合わせる実装になります。

検算つきのルートを自分のデータで回す

自分の配送データに置き換えるときは、CSVの列を保ったまま行を差し替え、VEHICLES と CAPACITY を実際の台数と上限に合わせます。そのうえで、検算がNGになるデータを1行だけ混ぜて、NGが出ることを確かめてから本番のデータを流します。

荷待ち時間のCSVの集計は、荷待ち時間の集計をClaudeで進める手順が扱っています。CSVを渡して計算結果を検算する流れは、Claude CodeでPLCのログ解析にも共通します。

台数や時間枠が増えて探索が重くなったときは、時間制限を延ばすか、初期解の戦略を変えて比べます。変更のたびに検算を回せば、速くなった代わりに制約が緩んだ、という退行も見つかります。

この記事を共有:XはてブLinkedIn