燃料補給施設配置問題(FRLM)入門¶

1. はじめに¶

FCLM 入門では、経路上に施設が1つでもあればその経路の流れを捕捉できる、という仮定で施設を配置した。しかし電気自動車の充電スタンドや水素ステーションのような燃料補給施設では、この仮定は成り立たない。車両には航続距離があり、長い経路では途中で複数回の補給が必要になる。つまり「経路上に施設があるか」ではなく「経路上の施設の組合せで航続距離をつなげられるか」が問題になる。

このように車両の航続距離を明示的に考慮した流れ捕捉型の施設配置問題を燃料補給施設配置問題(Flow-Refueling Location Model、FRLM)と呼ぶ。本資料ではFCLM との違いに注目しながら、FRLM を定式化してPuLP で解く。

2. 前提知識と学習目標¶

FCLM 入門を先に読んでいることを前提とする。経路データの持ち方(通過ノード列 $N_j$ と交通量 $f_j$)もFCLM 入門と共通である。

学習目標¶

  1. FCLM とFRLM の違い(航続距離と施設の組合せ)を説明できる。
  2. ある施設の組合せが経路の往復を補給可能にする条件を判定できる。
  3. 組合せの列挙を使ったFRLM の定式化をPuLP で実装できる。

3. 問題設定と定式化¶

3.1 問題の具体例¶

直線状の道路の上に5つのノード(1〜5)が5 km 間隔で並んでいる(位置はそれぞれ0、5、10、15、20 km)。施設を建てられる候補地は途中のノード2、3、4の3か所であり、候補地の集合を $K = \{2, 3, 4\}$ とする。建てる施設はちょうど $p$ か所である。

車両の流れは出発地から目的地までの往復であり、FCLM 入門と同じく、経路 $j$ の通過ノード列 $N_j$ と交通量 $f_j$ で表す。

経路 $j$ 通過ノード $N_j$ 交通量 $f_j$ [台/日]
1 1, 2, 3, 4, 5 100
2 1, 2, 3 60
3 2, 3, 4, 5 50
4 3, 4, 5 40

車両の航続距離は $R = 12$ km とする。車両は出発時に満タンであり、開設された施設で満タンまで補給できると仮定する。

3.2 補給可能の条件¶

経路(片道距離 $L$)上の施設の組合せ $h$ で往復できる条件を考える。出発地からの距離順に施設が $s_1 < s_2 < \cdots < s_k$ にあるとすると、次のすべてが必要である。

  1. 出発地から最初の施設まで: $s_1 \leq R$
  2. 隣り合う施設の間: $s_{l+1} - s_l \leq R$
  3. 最後の施設から目的地で折り返して戻るまで: $2(L - s_k) \leq R$

条件3が重要で、目的地に施設がなければ、最後の施設から目的地までを往復分の燃料で走らなければならない。帰り道の残りの区間は行きと同じ施設列を逆にたどるので、条件1と2で足りる。

例えば経路1($L = 20$)をノード4(位置15)の施設だけで支えようとすると、条件1で $s_1 = 15 > 12$ となり不可能である。ノード2と4の2か所なら、$5 \leq 12$、$15-5=10 \leq 12$、$2(20-15)=10 \leq 12$ ですべて満たされ、補給可能となる。

3.3 定式化¶

経路 $j$ を補給可能にする施設の組合せの集合を $H_j$ とする(3.2 の条件で事前に列挙しておく)。決定変数は次の3種類である。

  • $x_k$: 候補地 $k$ に施設を建てるなら1
  • $v_h$: 組合せ $h$ の施設がすべて建っているなら1
  • $y_j$: 経路 $j$ が補給可能になるなら1

$$ \begin{align} \max \ \ & z = \sum_{j \in J} f_j y_j \\ \text{s.t. } \ \ & y_j \leq \sum_{h \in H_j} v_h, \quad \forall j \in J, \\ & v_h \leq x_k, \quad \forall h, \ \forall k \in h, \\ & \sum_{k \in K} x_k = p, \\ & x_k, v_h, y_j \in \{0, 1\}. \end{align} $$

1本目の制約は「補給可能と数えてよいのは、有効な組合せが少なくとも1つ成立しているときだけ」、2本目は「組合せ $h$ が成立するには、$h$ に含まれる施設がすべて建っていること」を表す。目的関数と施設数制約はMCLP やFCLM と同じ構造であり、違いは「組合せ」を経由する点に集約されている。

4. Python + PuLP による実装¶

4.1 データと補給可能判定¶

In [1]:
import itertools
import pulp

d = [0, 5, 10, 15, 20]  # ノード1〜5の位置 [km](d[i-1] がノードi の位置)
K = [2, 3, 4]           # 施設の候補地
N = [[1, 2, 3, 4, 5], [1, 2, 3], [2, 3, 4, 5], [3, 4, 5]]  # 経路の通過ノード列
f = [100, 60, 50, 40]                                      # 経路の交通量 [台/日]
J = list(range(1, len(N) + 1))
R = 12  # 航続距離 [km]
p = 2   # 施設数


def refuelable(Nj, combo, R):
    """経路Nj の往復が、施設の組合せcombo で補給可能かを判定する"""
    org = d[Nj[0] - 1]
    L = d[Nj[-1] - 1] - org
    pts = [0] + sorted(d[k - 1] - org for k in combo)
    for a, b in zip(pts[:-1], pts[1:]):
        if b - a > R:                 # 条件1、2
            return False
    return 2 * (L - pts[-1]) <= R     # 条件3


# 各経路について補給可能な組合せ H_j を列挙する
H = []
for j in J:
    on_path = [k for k in K if k in N[j - 1]]
    combos = [h for r in range(1, len(on_path) + 1)
              for h in itertools.combinations(on_path, r)
              if refuelable(N[j - 1], h, R)]
    H.append(combos)
    print(f'経路{j}: 補給可能な組合せ {combos}')
経路1: 補給可能な組合せ [(2, 4), (3, 4), (2, 3, 4)]
経路2: 補給可能な組合せ [(2,), (3,), (2, 3)]
経路3: 補給可能な組合せ [(4,), (2, 4), (3, 4), (2, 3, 4)]
経路4: 補給可能な組合せ [(4,), (3, 4)]

経路1は1か所では支えられず、ノード2と4、またはノード3と4の組合せが必要になることが読み取れる。一方で経路3と4はノード4の1か所で足りる。

4.2 モデル構築と求解¶

In [2]:
all_combos = sorted({h for combos in H for h in combos})

prob = pulp.LpProblem('FRLM', pulp.LpMaximize)
x = pulp.LpVariable.dicts('x', K, cat='Binary')
v = pulp.LpVariable.dicts('v', range(len(all_combos)), cat='Binary')
y = pulp.LpVariable.dicts('y', J, cat='Binary')

prob += pulp.lpSum(f[j - 1] * y[j] for j in J)
for j in J:
    prob += y[j] <= pulp.lpSum(v[all_combos.index(h)] for h in H[j - 1])
for i, h in enumerate(all_combos):
    for k in h:
        prob += v[i] <= x[k]
prob += pulp.lpSum(x[k] for k in K) == p

prob.solve(pulp.PULP_CBC_CMD(msg=0))
print('求解結果:', pulp.LpStatus[prob.status])
print('捕捉交通量:', int(pulp.value(prob.objective)), '/', sum(f))
print('開設ノード:', [k for k in K if x[k].value() == 1])
print('補給可能な経路:', [j for j in J if y[j].value() == 1])
求解結果: Optimal
捕捉交通量: 250 / 250
開設ノード: [2, 4]
補給可能な経路: [1, 2, 3, 4]

$p = 2$ ではノード2と4(またはノード3と4)の開設で、4本すべての経路(250台/日)を補給可能にできる。

4.3 FCLMとの比較¶

同じデータを、航続距離を無視するFCLM(経路上に施設が1つあれば捕捉、候補は $K$)で解くとどうなるか。

In [3]:
def solve_fclm(p):
    prob = pulp.LpProblem('FCLM', pulp.LpMaximize)
    x = pulp.LpVariable.dicts('x', K, cat='Binary')
    y = pulp.LpVariable.dicts('y', J, cat='Binary')
    prob += pulp.lpSum(f[j - 1] * y[j] for j in J)
    for j in J:
        prob += y[j] <= pulp.lpSum(x[k] for k in K if k in N[j - 1])
    prob += pulp.lpSum(x[k] for k in K) == p
    prob.solve(pulp.PULP_CBC_CMD(msg=0))
    return int(pulp.value(prob.objective)), [k for k in K if x[k].value() == 1]


def solve_frlm(p, R):
    H = []
    for j in J:
        on_path = [k for k in K if k in N[j - 1]]
        H.append([h for r in range(1, len(on_path) + 1)
                  for h in itertools.combinations(on_path, r)
                  if refuelable(N[j - 1], h, R)])
    all_combos = sorted({h for combos in H for h in combos})
    prob = pulp.LpProblem('FRLM', pulp.LpMaximize)
    x = pulp.LpVariable.dicts('x', K, cat='Binary')
    v = pulp.LpVariable.dicts('v', range(len(all_combos)), cat='Binary')
    y = pulp.LpVariable.dicts('y', J, cat='Binary')
    prob += pulp.lpSum(f[j - 1] * y[j] for j in J)
    for j in J:
        prob += y[j] <= pulp.lpSum(v[all_combos.index(h)] for h in H[j - 1])
    for i, h in enumerate(all_combos):
        for k in h:
            prob += v[i] <= x[k]
    prob += pulp.lpSum(x[k] for k in K) == p
    prob.solve(pulp.PULP_CBC_CMD(msg=0))
    return int(pulp.value(prob.objective)), [k for k in K if x[k].value() == 1]


print('p | FCLM(航続距離を無視) | FRLM(R=12)')
for p_test in [1, 2, 3]:
    z1, s1 = solve_fclm(p_test)
    z2, s2 = solve_frlm(p_test, R)
    print(f'{p_test} | {z1} {s1} | {z2} {s2}')
p | FCLM(航続距離を無視) | FRLM(R=12)
1 | 250 [3] | 90 [4]
2 | 250 [2, 3] | 250 [2, 4]
3 | 250 [2, 3, 4] | 250 [2, 3, 4]

$p = 1$ の結果の差に注目してほしい。FCLM ではノード3の1か所で全交通量250を「捕捉」できると判定されるが、実際にはノード3の1か所では片道20 km の経路1に給油をつなげられない。FRLM の答えはノード4の90にとどまる。航続距離を無視すると施設の効果を大きく過大評価する、というのがFRLM の出発点である。

4.4 感度分析: 航続距離を変える¶

In [4]:
print('R | 捕捉交通量(p=2) | 開設ノード')
for R_test in [8, 10, 12, 16, 20]:
    z, s = solve_frlm(2, R_test)
    print(f'{R_test} | {z} | {s}')
R | 捕捉交通量(p=2) | 開設ノード
8 | 60 | [2, 3]
10 | 250 | [2, 4]
12 | 250 | [2, 4]
16 | 250 | [2, 4]
20 | 250 | [2, 3]

航続距離 $R$ が小さいと、施設をどう置いても長距離の経路は支えられず、捕捉量の上限自体が下がる。逆に $R$ が十分大きければFRLM の結果はFCLM に一致する。つまりFCLM は「航続距離が無限大のFRLM」とみなせる。

5. 道路ネットワークへの適用¶

第3節の直線状の道路の例を道路ネットワークに拡張する。使うのは「道路ネットワークデータの扱い方」(optimization/road-network/road_network.ipynb)で生成したダミー道路網と経路データ(N.csv、f.csv)であり、FCLM 入門の道路ネットワーク節と同じデータである。車両の航続距離は $R = 1600$ [m] とし、施設の候補はFCLM と同じく経路上に現れるすべてのノード(集合 $I$)とする。

直線状の道路との違いは、施設の位置を「経路に沿った距離」で測ることだけである。最短経路のノード列に沿って累積距離を計算すれば、3.2の補給可能条件をそのまま判定できる。なお組合せの列挙では、施設が1つもない組合せ(空集合)も含めて判定している。往復距離が $R$ 以下の経路は施設なしで走りきれるためだが、このデータでは最短の経路でも往復2322 [m] あり、$R = 1600$ ではそのような経路はない。

In [5]:
import numpy as np
import networkx as nx

# 道路網の読み込み(road_network.ipynb と同じ手順)
edgs = np.loadtxt('../road-network/links_ud.csv', delimiter=',')
G = nx.Graph()
G.add_weighted_edges_from([(int(e[0]), int(e[1]), e[2]) for e in edgs])
pos = {}
for e in edgs:
    pos[int(e[0])] = [e[3], e[4]]
    pos[int(e[1])] = [e[5], e[6]]

# 経路データの読み込み
N = []
with open('../road-network/N.csv', 'r') as fp:
    for row in fp.readlines():
        N.append([int(v) for v in row.strip().split(',') if len(v) > 0])
f = [int(v) for v in np.loadtxt('../road-network/f.csv')]
J = list(range(1, len(N) + 1))
I = sorted(set(sum(N, [])))
R_nw = 1600.0  # 航続距離 [m]


def cum_dist(Nj):
    """経路のノード列に沿った累積距離"""
    dd = [0.0]
    for u, v in zip(Nj[:-1], Nj[1:]):
        dd.append(dd[-1] + G[u][v]['weight'])
    return dd


def refuelable_path(Nj, combo, R):
    """経路Nj の往復が、施設の組合せcombo で補給可能かを判定する"""
    dd = cum_dist(Nj)
    L = dd[-1]
    pts = [0] + [dd[i] for i, nd in enumerate(Nj) if nd in combo]
    for a, b in zip(pts[:-1], pts[1:]):
        if b - a > R:                 # 条件1、2
            return False
    return 2 * (L - pts[-1]) <= R     # 条件3


# 各経路について補給可能な組合せ H_j を列挙する(候補は経路上の全ノード)
H_nw = []
for j in J:
    on_path = N[j - 1]
    combos = [h for r in range(len(on_path) + 1)
              for h in itertools.combinations(on_path, r)
              if refuelable_path(N[j - 1], h, R_nw)]
    H_nw.append(combos)
    L = cum_dist(N[j - 1])[-1]
    print(f'経路{j}: 経路長 {L:6.1f} [m], 補給可能な組合せの数 {len(combos)}')
経路1: 経路長 1787.6 [m], 補給可能な組合せの数 54
経路2: 経路長 1882.7 [m], 補給可能な組合せの数 46
経路3: 経路長 2034.4 [m], 補給可能な組合せの数 104
経路4: 経路長 1409.8 [m], 補給可能な組合せの数 28
経路5: 経路長 1292.0 [m], 補給可能な組合せの数 28
経路6: 経路長 1911.7 [m], 補給可能な組合せの数 54
経路7: 経路長 1161.3 [m], 補給可能な組合せの数 14

直線状の道路の例では組合せを数個ずつ列挙できたが、ネットワークでは経路が長く、補給可能な組合せの数が大きく増える。それでもモデルはまったく同じである。$p = 2$ で解く。

In [6]:
def solve_frlm_network(p, R):
    H = []
    for j in J:
        on_path = N[j - 1]
        H.append([h for r in range(len(on_path) + 1)
                  for h in itertools.combinations(on_path, r)
                  if refuelable_path(N[j - 1], h, R)])
    all_combos = sorted({h for combos in H for h in combos})
    prob = pulp.LpProblem('FRLM_network', pulp.LpMaximize)
    x = pulp.LpVariable.dicts('x', I, cat='Binary')
    v = pulp.LpVariable.dicts('v', range(len(all_combos)), cat='Binary')
    y = pulp.LpVariable.dicts('y', J, cat='Binary')
    prob += pulp.lpSum(f[j - 1] * y[j] for j in J)
    for j in J:
        prob += y[j] <= pulp.lpSum(v[all_combos.index(h)] for h in H[j - 1])
    for i, h in enumerate(all_combos):
        for k in h:
            prob += v[i] <= x[k]
    prob += pulp.lpSum(x[i] for i in I) == p
    prob.solve(pulp.PULP_CBC_CMD(msg=0))
    z = int(pulp.value(prob.objective))
    chosen = [i for i in I if x[i].value() == 1]
    captured = [j for j in J if y[j].value() == 1]
    return z, chosen, captured


z2, chosen2, captured2 = solve_frlm_network(2, R_nw)
print('p = 2 の捕捉交通量:', z2, '/', sum(f))
print('開設ノード:', chosen2)
print('補給可能な経路:', captured2)
p = 2 の捕捉交通量: 360 / 670
開設ノード: [15, 18]
補給可能な経路: [4, 5, 6, 7]

$p = 2$ の最適解はノード15と18の開設であり、捕捉交通量は360 [台/日]である。同じデータのFCLM(FCLM 入門の第5節)では最適配置はノード3と18の480だったから、航続距離を考慮すると最適な場所が変わることが分かる。FCLM で最も強かったノード3は、経路5(ノード3から27へ)の出発地であり、経路1(ノード1から6へ)でも序盤にある。出発地や序盤の施設では帰路までの燃料を支えられないため、FRLM ではほとんど価値を失い、代わりに経路の中央に近いノード15が選ばれる。「経路上にあるか」ではなく「経路上のどこにあるか」が効く、というFRLM らしい結果である。

念のため、施設の組合せを全列挙して最適値を確かめる。開設したノードは経路上のすべてで補給に使えるため、開設集合と経路の共通部分で補給可能性を判定すればよい。

In [7]:
from itertools import combinations

def frlm_value(open_nodes, R):
    return sum(fj for Nj, fj in zip(N, f)
               if refuelable_path(Nj, set(Nj) & set(open_nodes), R))

best = max(frlm_value(cb, R_nw) for cb in combinations(I, 2))
print('全列挙による p = 2 の最大捕捉交通量:', best)
print('数理モデルの解と一致:', best == z2)
全列挙による p = 2 の最大捕捉交通量: 360
数理モデルの解と一致: True
In [8]:
import matplotlib.pyplot as plt

# 可視化: 赤い実線 = 補給可能な経路、青い破線 = 補給できない経路
plt.figure(figsize=(8, 6))
nx.draw_networkx_edges(G, pos, edge_color='lightgray')
for j in J:
    if j not in captured2:
        nx.draw_networkx_edges(G, pos, edgelist=list(zip(N[j - 1][:-1], N[j - 1][1:])),
                               edge_color='steelblue', style='dashed', width=2)
for j in J:
    if j in captured2:
        nx.draw_networkx_edges(G, pos, edgelist=list(zip(N[j - 1][:-1], N[j - 1][1:])),
                               edge_color='red', width=2)
nx.draw_networkx_nodes(G, pos, node_size=170, node_color='white', edgecolors='gray')
nx.draw_networkx_nodes(G, pos, nodelist=chosen2, node_size=300, node_color='red',
                       edgecolors='black')
nx.draw_networkx_labels(G, pos, font_size=7)
plt.axis('equal')
plt.axis('off')
plt.tight_layout()
plt.show()
No description has been provided for this image

赤いノードが開設した施設である。施設数 $p$ と航続距離 $R$ を変えて、最適値の変化も確認する。

In [9]:
for p_test in [1, 2, 3, 4]:
    z, chosen, _ = solve_frlm_network(p_test, R_nw)
    print(f'p = {p_test}: 捕捉交通量 = {z} [台/日], 開設ノード = {chosen}')

print()
print('R [m] | 捕捉交通量(p = 2) | 開設ノード')
for R_test in [1200, 1600, 2000, 2400, 2800]:
    z, chosen, _ = solve_frlm_network(2, R_test)
    print(f'{R_test} | {z} | {chosen}')
p = 1: 捕捉交通量 = 150 [台/日], 開設ノード = [18]
p = 2: 捕捉交通量 = 360 [台/日], 開設ノード = [15, 18]
p = 3: 捕捉交通量 = 480 [台/日], 開設ノード = [4, 15, 18]
p = 4: 捕捉交通量 = 580 [台/日], 開設ノード = [4, 15, 18, 29]

R [m] | 捕捉交通量(p = 2) | 開設ノード
1200 | 300 | [21, 24]
1600 | 360 | [15, 18]
2000 | 360 | [15, 18]
2400 | 460 | [15, 30]
2800 | 520 | [6, 30]

施設数を増やすと捕捉交通量は150、360、480、580 [台/日]と増える。また航続距離 $R$ が小さいほど捕捉交通量は下がり、最適な開設場所も動く。$R = 2400$ 以上になると、往復距離が $R$ 以下の短い経路が「施設なしで走りきれる」ようになり、捕捉量の基礎値が上がっていく。なおこれらの感度分析では最適解が一意でない場合があり、表の開設ノードは最適解の1つである。

6. 発展: 研究紹介¶

FRLM の組合せ列挙は経路が長く候補地が多いほど爆発的に増えるため、実規模の道路ネットワークでは列挙の工夫や近似が研究されている。本研究室でも、フロー捕捉系のモデル(FCLM の派生)と組合せて道路ネットワーク上で比較実験を行っており、素材となった実装(最短経路の生成、経路別の組合せ列挙、Gurobi による求解)が研究資産として残っている。時間軸を持つ休憩施設配置(RALMTD)については、集合被覆問題(LSCP)入門の発展節を参照してほしい。

7. 参考文献¶

  • M. Kuby and S. Lim, The flow-refueling location problem for alternative-fuel vehicles, Socio-Economic Planning Sciences, vol.39, no.2, pp.125-145, 2005.

8. 課題¶

  1. 航続距離 $R = 12$ のまま、経路1を支えられる施設の組合せをすべて手で列挙し、4.1 の出力と一致することを確かめよ。
  2. 候補地を増やして(例えば位置2 km と18 km に候補ノードを追加して)、$p$ と捕捉量の関係がどう変わるか実験せよ。
  3. 「出発時は満タンではなく半分」という仮定に変えると、3.2 の条件はどう変わるか考えよ。

9. 注意事項¶

  • 本資料の補給可能条件は「出発時満タン、施設で満タン補給」という単純化した仮定に基づく。文献によって出発時の燃料量などの仮定が異なるので、論文を読むときは仮定を必ず確認すること。
  • 理解できない場合には指導教員まで相談すること。