# Freight service network design: classic literature reading notes reproduction Netplan

**Freight service network design: reading notes of classic literature (2) **It was mentioned that the model used in cranic T g (1984) should be reproduced, but the general framework given in the article is still too general. After trying, it was decided to use Netplan (1989) for LCL freight proposed by Jacques Roy & Louis Delorme on the basis of cranic T g (1984) as the blueprint of basic learning to realize the reproduction.

Netplan: A Network Optimization Model For Tactical Planning In The
Less-Than-Truckload Motor-Carrier Industry, INFOR: Information Systems and Operational Research, 27:1, 22-35, Jacques Roy & Louis Delorme (1989)
DOI: 10.1080/03155986.1989.11732079

## I. overview of the paper

The deregulation of Canadian road freight market and fierce market competition have led to the emergence of netplan as a decision-making tool. Netplan originates from the general framework of freight service network design model and algorithm, and integrates the characteristics of medium and long-term planning of LCL freight service.

### 1.2 three problems in the design of LCL freight service network

How to formulate the strategy of services between stations and transporting (separating and merging) goods on the network, so that the transportation strategy ensures to minimize the transportation and processing costs, while improving or at least maintaining the existing service level - Powell (1986)

The Load Plan must consider the following three interrelated sub problems:

• 1. Service Network Design, that is, select the route providing operator services and determine the characteristics of each service, such as mode, speed and frequency (vehicle departure times on a given route in each planning period)
• 2. The Traffic Distribution refers to which service sequence will be used to move the goods and which terminal the goods will pass through for each departure destination pair (or freight class) with positive freight demand
• 3. The Empty Vehicle Movement is to determine the number of empty vehicles required to move, so as to establish a balance between the number of departure vehicles and arrival vehicles at each originating terminal in the network

The third question is not explained in detail in the paper, so it is also ignored here for the time being.

## Second, reproduce the thesis model

The main task of Netplan model is to help LCL freight enterprises make medium and long-term load plans at the freight network level. The language used is python, the solver uses gurobipy, and networkx for drawing

import sys
import math
import random
import gurobipy as gp
from gurobipy import GRB
from gurobipy import *
import numpy as np
import networkx as nx
import matplotlib.pyplot as plt


### 2.1 input data simulation

The first problem encountered in reproducing the paper is that there is no data, so all the data need to be simulated according to a certain logic. Now let's take a look at the data that will be used?

#### 2.1.1 input data analysis

##### 2.1.1.1 cost data
• intercity transportation costs: varies with the type of service used and its frequency
• the freight consolidation costs: varies with the volume of freight handled at the Breakbulk
• the capacity penalty costs: the penalty incurred when the trailer capacity is overused in each transportation service
• the service penalty costs: the penalty incurred when the service performance standard is not met, taking into account the average value and variance of the planned transportation time of each freight class

The relevant costs and cost units are listed above. Step by step, this paper only considers the first cost.

##### 2.1.1.2 basic information

Here, the required information is listed first. Since a cost is considered first for the time being, some known information will not be used for the time being (so it does not need to be simulated this time)

#### 2.1.2 code generated by input data

# Example generation for the article NETPLAN(1989)
# Node set, number + coordinates

N = dict()
# Transit cost per node
Cn = dict()
for n in range(6):
N.update({n:np.array([random.randint(0,20),random.randint(0,20)],dtype = np.float32)}) #Number + coordinates
Cn.update({n:random.randint(5,10)}) # Transit cost per node

# Randomly generate the freight demand of OD pairs, and temporarily ignore the freight demand penalty and transportation time requirements
D = dict()
for i in range(30):
o = random.randint(0,len(N)-1)
d = random.randint(0,len(N)-1)
while o == d:
d = random.randint(0,len(N)-1)
D.update({(o,d):random.randint(30,200)})


When I simulated here, I chose the idea of demand driven. Firstly, the set of less than carload freight demand is randomly generated. The freight demand is composed of OD pairs and corresponding freight volume, which is stored by dictionary type.

Therefore, in order to simplify the service path, only different service paths are defined. In this way, it is necessary to formulate path search rules, and before implementing these path search rules by program, it is necessary to determine the representation of the network.

The freight demand dictionary can also be regarded as a freight demand network, which is a directed network represented by an arc. [ps, edge generally refers to undirected connection, and arc refers to directed connection)

However, I don't think the network represented by arc is easy to use when searching the path, so I regenerate the dictionary type variable snet in the code. Its representation takes the node as the key and the node points to the node list as the value.

def find_degree(n, edges,out):
'''
Find the starting point in the graph represented by edge set n Point of arrival after departure
Or arrival point n Starting point
The Function is used to find the arriving node or
the starting node for the node n in the net which is
described by the edges set.
thus parameter n indicates the node;
edges is the net which is described by edges set;
out indicates whether it is to find the arriving node (out =1)
or the starting node (out =0).
'''
degree_n = []
if out == 0:
for i in edges:
if n == i[0]:
degree_n.append(i[1])
else:
for i in edges:
if n == i[1]:
degree_n.append(i[0])
return degree_n

# Generate demand network
snet = dict()
for i in N.keys():
deout = find_degree(i, D.keys(),0)
snet.update({i:deout})


snet is used to generate services defined simply by paths

# Generate the service and the arc that the service passes through
L = dict()
#Service capacity
alpha = dict()
# Transportation unit cost of service
Cl = dict()
# Based on the demand network, generate the path passing through no more than two transit nodes, and then randomly generate the service capacity and service cost
k = 0
for o in range(len(N)):
for d in range(o+1,len(N)):
for l in searchGraph(snet, o, d): # More than 2 transit paths are not used
# Triangle relation satisfying coordinates
if len(l) == 3:
l01 = ((N[l[0]][0] - N[l[1]][0])**2 + (N[l[0]][1] - N[l[1]][1])**2 )**0.5
l02 = ((N[l[0]][0] - N[l[2]][0])**2 + (N[l[0]][1] - N[l[2]][1])**2 )**0.5
l12 = ((N[l[1]][0] - N[l[2]][0])**2 + (N[l[1]][1] - N[l[2]][1])**2 )**0.5
indicator = l01 + l12 > l02
if indicator == True:
L.update({k:l})
Cl.update({k:random.randint(10,20)})
alpha.update({k:random.randint(2,4)*100})
k = k+1
elif len(l) == 4:
l01 = ((N[l[0]][0] - N[l[1]][0])**2 + (N[l[0]][1] - N[l[1]][1])**2 )**0.5
l02 = ((N[l[0]][0] - N[l[2]][0])**2 + (N[l[0]][1] - N[l[2]][1])**2 )**0.5
l03 = ((N[l[0]][0] - N[l[3]][0])**2 + (N[l[0]][1] - N[l[3]][1])**2 )**0.5
l12 = ((N[l[1]][0] - N[l[2]][0])**2 + (N[l[1]][1] - N[l[2]][1])**2 )**0.5
l13 = ((N[l[1]][0] - N[l[3]][0])**2 + (N[l[1]][1] - N[l[3]][1])**2 )**0.5
l23 = ((N[l[2]][0] - N[l[3]][0])**2 + (N[l[2]][1] - N[l[3]][1])**2 )**0.5
l03 = ((N[l[0]][0] - N[l[3]][0])**2 + (N[l[0]][1] - N[l[3]][1])**2 )**0.5

ind1 = (l01 + l12) > l02
ind2 = (l12 + l23) > l13
ind3 = l03 > (l01+l12+l23)
ind4
ind5 = (l23 >= (0.3*(l01+l12+l23)))
indicator1 = (ind1 & ind2 & ind3 & ind4)
if indicator1 == True:
L.update({k:l})
Cl.update({k:random.randint(10,20)})
alpha.update({k:random.randint(2,4)*100})
k = k+1

else:
L.update({k:l})
Cl.update({k:random.randint(10,20)})
alpha.update({k:random.randint(2,4)*100})
k = k+1


Although the code generated by the above service calculates the length of the path and maintains the triangular balance of the path, it will still generate a seemingly unreasonable path. However, because it is only simulation, we will temporarily give up the rules for further refining the path generation.

Therefore, more rules are needed in path generation to standardize the generated services. Of course, in practice, the service type is given by the freight enterprise, or it is also a small optimization problem (for example, the shortest path under different steps)

So far, the basic data can be simulated. The following is the further calculation on these data.

### 2.2 model data analysis

To learn step by step, this article only considers the transportation cost of using each service. One constraint is to allocate the freight volume in the service set that each freight class m can choose; The second constraint is that the freight volume cannot be negative. The third constraint is that the number of services is limited, a service can not be used, and the number of services is an integer. It can be found that except the first constraint, other constraints are constraints on the value range of decision variables, which can be realized directly in the function defining decision variables in programming.
Here is the code for Gurobipy to build the model.

# model building
Model = gp.Model('fsnd')

# Frequency of service l
F_l = Model.addVars(L,lb=0, vtype =GRB.INTEGER, name='Fre')

#Traffic volume on alternative route k of each freight flow m
X_mk = dict()
for m in m_path:
for k in m_path[m]:

# Minimize transportation costs
objective = Model.setObjective(gp.quicksum(F_l[l] * Cl[l] for l in L),GRB.MINIMIZE)

# For any freight class m, the total transportation volume on k routes is equal to the transportation demand
i = 0
for m in m_path:
Model.addConstr(gp.quicksum(X_mk[m,k] for k in m_path[m].keys())== D[m],name=f'C{i}')
i = i + 1

# For any service, the number of times should be greater than the required transportation volume
for l in L:
Model.addConstr(gp.quicksum(X_mk[m,l] for m in l_mset[l])<=F_l[l]*alpha[l], name=f'C{i}')
i = i + 1

Model.optimize()

# Model error checking
if Model.status == GRB.Status.INFEASIBLE:
print('Optimization was stopped with status %d' % Model.status)
# do IIS, find infeasible constraints
Model.computeIIS()
for c in Model.getConstrs():
if c.IISConstr:
print('%s' % c.constrName)



In the generation of decision variables, it is worth mentioning that in fact, the number of alternative services of each freight class is different. Before building the model, it is necessary to screen out the set of alternative services of each freight class m.

# Optional service l set for each freight demand
m_path = dict()
for m in D:
path = dict()
for l in L:
o_ind = findindex(m[0],L[l])
d_ind = findindex(m[1],L[l])
if 0 <= o_ind < d_ind:
path.update({l:L[l]})
m_path.update({m:path})


The constraint of this model actually restricts the number of services in the overload penalty cost of the objective function X l α l F l \frac{X_l}{\alpha_l F_l} α l​Fl​Xl​​. there X l X_l Xl ， is an intermediate variable, because I don't consider the cost of overload here, so I add this constraint to the code implementation

If you write a formula
∑ m ∈ M ( l ) X k m < = F l α l ∀ l \sum_{m \in M(l)}{X^m_k } <=F_l\alpha_l \quad \forall l ∑m∈M(l)​Xkm​<=Fl​αl​∀l

X l = ∑ m ∈ M ( l ) X k m ∀ l X_l = \sum_{m \in M(l)}{X^m_k } \quad \forall l Xl​=∑m∈M(l)​Xkm​∀l
M ( l ) M(l) M(l) is the set of freight classes m applicable to each freight service l.

The following is the generation M ( l ) M(l) Code of M(l)

# For each service l, the freight class m set of possible services
l_mset = dict()
for l in L:
mdict = dict()
for m in m_path:
if l in m_path[m].keys():
mdict.update({m:0})
l_mset.update({l:mdict})


Code corresponding to constraint

# For any service, the number of times should be greater than the required transportation volume
for l in L:
Model.addConstr(gp.quicksum(X_mk[m,l] for m in l_mset[l])<=F_l[l]*alpha[l], name=f'C{i}')
i = i + 1


#### 2.3 data output

The data output part is mainly to visualize the results of gurobi solution.

Here is a log of one-time examples

Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (win64)
Thread count: 4 physical cores, 8 logical processors, using up to 8 threads
Optimize a model with 76 rows, 259 columns and 458 nonzeros
Model fingerprint: 0x6b2ec18d
Variable types: 0 continuous, 259 integer (0 binary)
Coefficient statistics:
Matrix range     [1e+00, 4e+02]
Objective range  [1e+01, 2e+01]
Bounds range     [0e+00, 0e+00]
RHS range        [8e+01, 2e+02]
Found heuristic solution: objective 218.0000000
Presolve time: 0.00s
Presolved: 76 rows, 259 columns, 458 nonzeros
Variable types: 0 continuous, 259 integer (17 binary)

Root relaxation: objective 6.456032e+01, 131 iterations, 0.00 seconds

Nodes    |    Current Node    |     Objective Bounds      |     Work
Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

0     0   64.56032    0    9  218.00000   64.56032  70.4%     -    0s
H    0     0                     121.0000000   64.56032  46.6%     -    0s
H    0     0                     111.0000000   64.56032  41.8%     -    0s
H    0     0                      98.0000000   64.56032  34.1%     -    0s
H    0     0                      95.0000000   64.56032  32.0%     -    0s
H    0     0                      92.0000000   64.56032  29.8%     -    0s
0     0   68.23180    0   21   92.00000   68.23180  25.8%     -    0s
H    0     0                      77.0000000   68.23180  11.4%     -    0s
0     0   68.37794    0   19   77.00000   68.37794  11.2%     -    0s
0     0   68.38825    0   25   77.00000   68.38825  11.2%     -    0s
0     0   70.02606    0   18   77.00000   70.02606  9.06%     -    0s
0     0   70.02606    0    8   77.00000   70.02606  9.06%     -    0s
0     0   70.02606    0   11   77.00000   70.02606  9.06%     -    0s
0     0   70.02606    0   21   77.00000   70.02606  9.06%     -    0s
0     0   70.09949    0   18   77.00000   70.09949  8.96%     -    0s
0     0   71.47247    0   31   77.00000   71.47247  7.18%     -    0s
0     0   71.47286    0   31   77.00000   71.47286  7.18%     -    0s
0     0   71.62734    0   18   77.00000   71.62734  6.98%     -    0s
0     0   71.66762    0   38   77.00000   71.66762  6.93%     -    0s
0     0   71.68363    0   38   77.00000   71.68363  6.90%     -    0s
H    0     0                      76.0000000   71.68363  5.68%     -    0s
0     0   71.71158    0   15   76.00000   71.71158  5.64%     -    0s
0     0   71.71158    0   19   76.00000   71.71158  5.64%     -    0s
0     0   71.74907    0   45   76.00000   71.74907  5.59%     -    0s
0     0   71.81435    0   28   76.00000   71.81435  5.51%     -    0s
H    0     0                      74.0000000   71.81435  2.95%     -    0s
0     0   72.17717    0   24   74.00000   72.17717  2.46%     -    0s
0     0   72.17717    0   19   74.00000   72.17717  2.46%     -    0s
0     0   72.17717    0    8   74.00000   72.17717  2.46%     -    0s
0     0   72.17717    0   17   74.00000   72.17717  2.46%     -    0s
0     0   72.63333    0   22   74.00000   72.63333  1.85%     -    0s
0     0   73.18750    0   26   74.00000   73.18750  1.10%     -    0s

Cutting planes:
Gomory: 3
MIR: 13
Relax-and-lift: 2

Explored 1 nodes (554 simplex iterations) in 0.13 seconds
Thread count was 8 (of 8 available processors)

Solution count 9: 74 76 77 ... 218

Optimal solution found (tolerance 1.00e-04)
Best objective 7.400000000000e+01, best bound 7.400000000000e+01, gap 0.0000%


Output results

Freight (2, 0)_ 58: 81.0:[4, 2, 0, 5]
Freight (1, 4)_ 15: 41.0:[1, 0, 4, 2]
Freight (1, 4)_ 19: 39.0:[1, 4, 2, 3]
Freight (1, 4)_ 30: 109.0:[1, 3, 4, 5]
Freight (4, 2)_ 15: 189.0:[1, 0, 4, 2]
Freight (1, 0)_ 15: 149.0:[1, 0, 4, 2]
Freight (1, 3)_ 19: 94.0:[1, 4, 2, 3]
Freight (5, 2)_ 4: 100.0:[0, 4, 5, 2]
Freight (2, 5)_ 54: 78.0:[3, 2, 1, 5]
Freight (0, 4)_ 4: 188.0:[0, 4, 5, 2]
Freight (2, 3)_ 19: 167.0:[1, 4, 2, 3]
Freight (4, 5)_ 4: 64.0:[0, 4, 5, 2]
Freight (4, 5)_ 30: 23.0:[1, 3, 4, 5]
Freight (0, 5)_ 4: 48.0:[0, 4, 5, 2]
Freight (0, 5)_ 58: 58.0:[4, 2, 0, 5]
Freight (4, 0)_ 58: 161.0:[4, 2, 0, 5]
Freight (1, 5)_ 30: 73.0:[1, 3, 4, 5]
Freight (1, 5)_ 54: 54.0:[3, 2, 1, 5]
Freight (3, 4)_ 30: 195.0:[1, 3, 4, 5]
Freight (2, 1)_ 54: 88.0:[3, 2, 1, 5]
Freight (3, 2)_ 54: 180.0:[3, 2, 1, 5]
Service number: 4, service path: [0, 4, 5, 2], service capacity: 400, service times: 1.0, service freight volume: 49
Service number: 15, service path: [1, 0, 4, 2], service capacity: 400, service times: 1.0, service freight volume: 53
Service number: 19, service path: [1, 4, 2, 3], service capacity: 300, service times: 1.0, service freight volume: 45
Service number: 30, service path: [1, 3, 4, 5], service capacity: 400, service times: 1.0, service freight volume: 61
Service number: 54, service path: [3, 2, 1, 5], service capacity: 400, service times: 1.0, service freight volume: 48
Service number: 58, service path: [4, 2, 0, 5], service capacity: 300, service times: 1.0, service freight volume: 35

Network visualization before and after one optimization (independent of the above text results)

Optimize the first 21 service demand arcs

The optimized 12 service demand arcs reduce the cost by combining the freight demand
Detail 1

Detail 2
It can be seen that the freight demand is indeed consolidated, which can also explain why sometimes our express will first detour and then be transported. This is just that the freight company is saving costs. Of course, if the service time is considered, the result will be different.

Keywords: Python

Added by EvilWalrus on Fri, 18 Feb 2022 03:24:52 +0200