项目背景
在做某城市的大数据平台时,其中一个模块是做交通模型,为了将模型做的更精细,我们考虑在重要交叉口处考虑转向延误,在之前的一些城市路网模型中,一个交叉口可能只是被简单抽象成下图的样子:
如果我们要考虑link33(4-1方向)到link22(1-3方向)的左转延误,该如何考虑呢,基于目前的最短路算法,必须要提供一条边用于表示左转的时间权重,但是目前这种路网的表达形式却是没有具体的转向边的,那么就有三种方案:改写底层的最短路算法专门处理转向延误、扩展路口、利用对偶图理论进行路网转换。这里简单介绍一下扩展路口和利用对偶图这两种做法。
扩展路口
扩展简介
扩展路口这种做法就是从改造路网本身的拓扑结构出发,扩展出路口的左转、右转、直行以及掉头车道,扩展后的路网如下图所示(考虑掉头的扩展)。这种考虑掉头的扩展得到的每一条扩展车道都是单向车道,因此我们可以直接赋予扩展车道对应的时间延误值,然后组网即可进行路径搜索。
如果不考虑掉头,那么可以简化为下图:
由于不考虑掉头,则不需要在节点6、7、8、9处分裂出两条单行道。只需要生成四条左右转双向道路即可(link55、link66、link77、link88),再生成两条直行专用道link89、link90
扩展实现思路
假设我们得到的输入是为扩展前的简单路口,类似上面第一张图,当然他也可能是三叉路口、五岔路口甚至N岔路口,我们如何将扩展的流程进行梳理呢?
以上图简单路口为例,不考虑掉头的扩展方法,假设道路都是双向通行的,即dir=0:
-
首先要先分析每一条进口道可以联通的出口道,进口道有3-1,4-1,5-1,2-1,出口道有1-3,1-4,1-5,1-2,进口道起始节点有2,3,4,5,对于进口道3-1,节点3可以经由节点1到达2、5、4,对于进口道4-1,节点3可以经由节点1到达2、5、3...然后我们可以得到扩展前的连通关系哈希表origin_connect_dict = {3: 2, 3: 5, 3: 4, 4: 5, 4: 2, 4: 3, 5: 2, 5: 3, 5: 4, 2: 3, 2: 4, 2: 5};
-
接着,毫无疑问,交叉口节点1肯定是要被删除的,删除后,与其关联的link分别生成一个新节点(6\7\8\9),四条新的link:2-6(双向),5-7(双向),4-8(双向),3-9(双向),此时的路网联通关系我们称之为temp_connect。
-
生成新的节点后我们要分析新生成的节点之间该如何连接,这就需要用到之前的连通关系,分析节点3,原始图中3可以到达2,我们只需在temp_connect中找到一组联通关系(3-x,y-2),那么x-y就是扩展后的新的联通关系,遍历所有进口道起始节点,进行搜索,即可以可得到扩展后的路口联通关系。
扩展实现代码
实际要考虑的东西挺多的
# -- coding: utf-8 --
# @Time : 2021/7/13 0013 17:29
# @Author : TangKai
# @File : intersection_kz_a.py
"""不考虑掉头的路口扩展法"""
import networkx as nx
import geopandas as gpd
import pandas as pd
from shapely.geometry import LineString, Point
from src.tools.net_modification import split_link_in_node # 这个函数没有贴出来, 可在gitHub链接中找到
import matplotlib.pyplot as plt
# 线层数据、点层数据必需字段
length_field = 'length' # 线层的长度, km
direction_field = 'dir' # 线层的方向, 0, 1, -1
link_id_field = 'link_id' # 线层的id
from_node_id_field = 'from_node' # 线层的拓扑起始结点
to_node_id_field = 'to_node' # 线层的拓扑终到结点
node_id_field = 'node_id' # 点层的id
geometry_field = 'geometry' # 几何属性字段
required_link_filed_list = \
[link_id_field, from_node_id_field, to_node_id_field, length_field, direction_field, geometry_field]
required_node_filed_list = [node_id_field, geometry_field]
# 扩展交叉口结点主函数
# 只考虑link_id, dir, from_node, to_node, length, geometry
def extend_intersection(intersection_node_id=None, link_gdf=None, node_gdf=None):
"""扩展交叉口结点, 直接在线层数据和点层数据上修改
:param intersection_node_id: int, 交叉口结点id
:param link_gdf: gpd.GeoDataFrame, 线层数据
:param node_gdf: gpd.GeoDataFrame, 点层数据
:return:
"""
# 交叉口节点的索引
inter_node_index = list(node_gdf[node_gdf[node_id_field] == intersection_node_id].index)[0]
# 得到和交叉口节点连接的局部路网
sub_link_gdf = get_sub_shp(link_shp=link_gdf, intersection_node=intersection_node_id,
from_node_field=from_node_id_field, to_node_field=to_node_id_field)
# 使用局部的网络图建立有向图
sub_graph = get_graph_from_link_gdf(link_gdf=sub_link_gdf, dir_col=direction_field,
from_node_col=from_node_id_field, to_node_col=to_node_id_field)
# 找出除开交叉口节点之外的节点
other_node_list = list(sub_graph.nodes)
other_node_list.remove(intersection_node_id)
path_dict = dict()
# 寻找原交叉口中联通的结点
for i in range(0, len(other_node_list)):
for j in range(0, len(other_node_list)):
if i != j:
if nx.has_path(sub_graph, other_node_list[i], other_node_list[j]):
if other_node_list[i] in path_dict.keys():
path_dict[other_node_list[i]].append(other_node_list[j])
else:
path_dict[other_node_list[i]] = [other_node_list[j]]
else:
pass
# 在交叉口节点处打断, 交叉口结点被删除, 对于N路交叉路口, 生成N个新结点
link_gdf, node_gdf, new_node_dict = split_link_in_node(link_gdf=link_gdf, node_gdf=node_gdf,
match_node_index=inter_node_index,
node_columns_list=None,
truncate_ratio=0.7)
# 确定新生成结点的联通性
from_node_list = []
to_node_list = []
for key in path_dict.keys():
for value in path_dict[key]:
if key in new_node_dict.keys() and value in new_node_dict.keys():
from_node_list.append(new_node_dict[key])
to_node_list.append(new_node_dict[value])
new_link_df = pd.DataFrame({'from_node': from_node_list, 'to_node': to_node_list})
new_link_df['from_to'] = new_link_df[['from_node', 'to_node']]. \
apply(lambda x: '_'.join(map(str, sorted([x[0], x[1]]))), axis=1)
def merge_single(single_df):
used_df = single_df.copy()
used_df[direction_field] = 1
if len(used_df) == 1:
return used_df
else:
used_df[direction_field] = 0
return used_df.iloc[[0], :]
# 双向路段融合
new_link_df = new_link_df.groupby('from_to').apply(merge_single)
new_link_df.reset_index(inplace=True, drop=True)
new_link_df.drop(columns='from_to', axis=1, inplace=True)
node_gdf.set_index(node_id_field, inplace=True)
new_link_df['from_point'] = new_link_df['from_node'].apply(lambda x: node_gdf.loc[x, geometry_field])
new_link_df['to_point'] = new_link_df['to_node'].apply(lambda x: node_gdf.loc[x, geometry_field])
new_link_df[geometry_field] = new_link_df[['from_point', 'to_point']]. \
apply(lambda x: LineString((x[0], x[1])), axis=1)
new_link_df[length_field] = new_link_df[geometry_field].apply(lambda x: x.length / 1000)
new_link_df.drop(columns=['from_point', 'to_point'], inplace=True, axis=1)
node_gdf.reset_index(inplace=True, drop=False)
new_link_gdf = gpd.GeoDataFrame(new_link_df, geometry=geometry_field)
max_link_id = max(link_gdf[link_id_field].to_list())
new_link_gdf[link_id_field] = [i + max_link_id for i in range(1, len(new_link_gdf) + 1)]
# 其他列数据
other_link_df = pd.DataFrame()
new_link_gdf = pd.concat([new_link_gdf, other_link_df], axis=1)
link_gdf = link_gdf.append(new_link_gdf)
link_gdf.reset_index(inplace=True, drop=True)
return link_gdf, node_gdf
# 将具有方向字段的路网格式转化为单向的路网格式(没有方向字段, 仅靠from_node, to_node即可判别方向)
def get_single_net(net_data=None, cols_field_name_list=None, dir_field_name=None,
from_node_name=None, to_node_name=None, geo_bool=True):
"""将具有方向字段的路网格式转化为单向的路网格式(没有方向字段, 仅靠from_node, to_node即可判别方向)
:param net_data: pd.DataFrame, 原路网数据
:param cols_field_name_list: list, 列名称列表
:param dir_field_name: str, 原路网数据代表方向的字段名称
:param from_node_name: str, 原路网数据代表拓扑起始结点的字段名称
:param to_node_name: str, 原路网数据代表拓扑终端结点的字段名称
:param geo_bool: bool, 路网数据是否带几何列
:return: gpd.DatFrame or pd.DatFrame
"""
if cols_field_name_list is None:
cols_field_name_list = list(net_data.columns)
# 找出双向字段, 双向字段都应该以_ab或者_ba结尾
two_way_field_list = list()
for cols_name in cols_field_name_list:
if cols_name.endswith('_ab') or cols_name.endswith('_ba'):
two_way_field_list.append(cols_name[:-3])
two_way_field_list = list(set(two_way_field_list))
ab_field_del = [x + '_ab' for x in two_way_field_list]
ba_field_del = [x + '_ba' for x in two_way_field_list]
ab_rename_dict = {x: y for x, y in zip(ab_field_del, two_way_field_list)}
ba_rename_dict = {x: y for x, y in zip(ba_field_del, two_way_field_list)}
# 方向为拓扑反向的
net_negs = net_data[net_data[dir_field_name] == -1].copy()
net_negs.drop(ab_field_del, axis=1, inplace=True)
net_negs.rename(columns=ba_rename_dict, inplace=True)
net_negs['temp'] = net_negs[from_node_name]
net_negs[from_node_name] = net_negs[to_node_name]
net_negs[to_node_name] = net_negs['temp']
if geo_bool:
net_negs[geometry_field] = net_negs[geometry_field].apply(lambda x: LineString(list(x.coords)[::-1]))
net_negs.drop(['temp', dir_field_name], inplace=True, axis=1)
# 方向为拓扑正向的
net_poss = net_data[net_data[dir_field_name] == 1].copy()
net_poss.drop(ba_field_del, axis=1, inplace=True)
net_poss.rename(columns=ab_rename_dict, inplace=True)
net_poss.drop([dir_field_name], inplace=True, axis=1)
# 方向为拓扑双向的, 改为拓扑正向
net_zero_poss = net_data[net_data[dir_field_name] == 0].copy()
net_zero_poss[dir_field_name] = 1
net_zero_poss.drop(ba_field_del, axis=1, inplace=True)
net_zero_poss.rename(columns=ab_rename_dict, inplace=True)
net_zero_poss.drop([dir_field_name], inplace=True, axis=1)
# 方向为拓扑双向的, 改为拓扑反向
net_zero_negs = net_data[net_data[dir_field_name] == 0].copy()
net_zero_negs.drop(ab_field_del, axis=1, inplace=True)
net_zero_negs.rename(columns=ba_rename_dict, inplace=True)
net_zero_negs['temp'] = net_zero_negs[from_node_name]
net_zero_negs[from_node_name] = net_zero_negs[to_node_name]
net_zero_negs[to_node_name] = net_zero_negs['temp']
if geo_bool:
net_zero_negs[geometry_field] = net_zero_negs[geometry_field].apply(lambda x: LineString(list(x.coords)[::-1]))
net_zero_negs.drop(['temp', dir_field_name], inplace=True, axis=1)
net = net_poss.append(net_zero_poss, ignore_index=True)
net = net.append(net_negs, ignore_index=True)
net = net.append(net_zero_negs, ignore_index=True)
return net
# 根据传入的交叉口节点id, 抽取出交叉口部分的shp数据
def get_sub_shp(link_shp=None, intersection_node=None, from_node_field=None, to_node_field=None):
"""
根据传入的交叉口节点id, 抽取出交叉口部分的shp数据
:param link_shp: gpd.DataFrame, 线层数据
:param intersection_node: int, 交叉口的节点id
:param from_node_field: str, 线层数据中代表link起始节点的字段名称
:param to_node_field: str, 线层数据中代表link终到节点的字段名称
:return: gpd.DataFrame, 交叉口局部的路网shp
"""
# 取交叉口附近的数据
sub_link_shp = link_shp[(link_shp[from_node_field] == intersection_node) |
(link_shp[to_node_field] == intersection_node)].copy()
# 重设索引
sub_link_shp.reset_index(inplace=True)
return sub_link_shp
# 从线层数据中构造有向图
def get_graph_from_link_gdf(link_gdf=None, dir_col=None, from_node_col=None, to_node_col=None):
"""从线层数据中构造有向图
:param link_gdf: gpd.DataFrame, 线层数据
:param dir_col: str, 方向字段名称
:param from_node_col: str, 路网线层数据中代表拓扑起始结点的字段名称
:param to_node_col: str, 路网线层数据中代表拓扑终到结点的字段名称
:return:
"""
used_link_gdf = link_gdf.copy()
def get_edge(direction, from_node, to_node):
edge_list = list()
if direction == 0:
edge_list.append([from_node, to_node])
edge_list.append([to_node, from_node])
elif direction == -1:
edge_list.append([to_node, from_node])
else:
edge_list.append([from_node, to_node])
return edge_list
used_link_gdf['edge_list'] = link_gdf[[dir_col, from_node_col, to_node_col]].\
apply(lambda x: get_edge(x[0], x[1], x[2]), axis=1)
di_edge_list = list(used_link_gdf['edge_list'].apply(lambda x: pd.Series(x)).stack().reset_index(level=1, drop=True))
d_graph = nx.DiGraph()
d_graph.add_edges_from(di_edge_list)
return d_graph
def convert_to_gdf_by_wkt(df=None, wkt_cols=None, crs=None):
"""从带几何列数据的文本数据创建地理数据
:param df: pd.DataFrame
:param wkt_cols:
:param crs:
:return:
"""
df['geometry'] = gpd.GeoSeries.from_wkt(df[wkt_cols])
gdf = gpd.GeoDataFrame(df, geometry='geometry', crs=crs)
return gdf
if __name__ == '__main__':
node1 = Point(1, 1)
node2 = Point(0, 1)
node3 = Point(1, 0)
node4 = Point(2, 1)
node5 = Point(1, 2)
link11 = LineString([node2, node1])
link22 = LineString([node3, node1])
link33 = LineString([node4, node1])
link44 = LineString([node5, node1])
link_geo = gpd.GeoDataFrame(pd.DataFrame({'link_id': [11, 22, 33, 44],
'dir': [1, 0, 0, 0],
'from_node': [2, 3, 4, 5],
'to_node': [1, 1, 1, 1],
'length': [link11.length, link22.length, link33.length, link44.length],
'geometry': [link11, link22, link33, link44]}),
geometry='geometry')
node_geo = gpd.GeoDataFrame(pd.DataFrame({'node_id': [1, 2, 3, 4, 5],
'geometry': [node1, node2, node3, node4, node5]}),
geometry='geometry')
print(link_geo)
extend_link, extend_node = extend_intersection(intersection_node_id=1, link_gdf=link_geo, node_gdf=node_geo)
print(extend_link)
print(extend_node)
ax = extend_link.plot()
extend_node.plot(ax=ax)
plt.show()
extend_link部分字段:
link_id from_node to_node dir geometry
0 11 2 6 1 LINESTRING (0.00000 1.00000, 0.70000 1.00000)
1 22 3 7 0 LINESTRING (1.00000 0.00000, 1.00000 0.70000)
2 33 4 8 0 LINESTRING (2.00000 1.00000, 1.30000 1.00000)
3 44 5 9 0 LINESTRING (1.00000 2.00000, 1.00000 1.30000)
4 45 6 7 1 LINESTRING (0.70000 1.00000, 1.00000 0.70000)
5 46 6 8 1 LINESTRING (0.70000 1.00000, 1.30000 1.00000)
6 47 6 9 1 LINESTRING (0.70000 1.00000, 1.00000 1.30000)
7 48 7 8 0 LINESTRING (1.00000 0.70000, 1.30000 1.00000)
8 49 7 9 0 LINESTRING (1.00000 0.70000, 1.00000 1.30000)
9 50 8 9 0 LINESTRING (1.30000 1.00000, 1.00000 1.30000)
extend_node:
node_id geometry
0 2 POINT (0.00000 1.00000)
1 3 POINT (1.00000 0.00000)
2 4 POINT (2.00000 1.00000)
3 5 POINT (1.00000 2.00000)
4 6 POINT (0.70000 1.00000)
5 7 POINT (1.00000 0.70000)
6 8 POINT (1.30000 1.00000)
7 9 POINT (1.00000 1.30000)
测试案例简单可视化:
完整工程见GitHub:
对偶图
对偶图思路就是将原图的边转化为节点,原图的节点转化为边,依据原图的联通关系构造出对偶图的联通关系。
考虑上图的网络(黑色数字是边权重,正反向权重一致),原图有7条双向link,相当于有14条有向边,即在对偶图中有14个节点,原边7-1(新节点7-1)与原边1-3(新节点1-3)联通,对应对偶图中的新有向边 (新节点7-1, 新节点1-3),依次类推,可以得到对偶图如下:
对偶图的节点权重为:该对偶节点在原图中对应的边的权重,对偶图的的有向边权重为:下游对偶节点在原图中对应的有向边的权重,对偶节点7-1,其在原图对应的有向边为link7-1,所以节点权重为1.2,对偶边(7-1,1-3)下游对偶节点为1-3,对应原图的link1-3,所以其边权重为1.8。
得到这样的对偶图就能很好的解决我们添加转向延误的问题,若我想添加原图link7-1 ~ link1-3的左转延误为30s,构造对偶图后,只需修改对偶边(7-1, 1-3)的权重为(30s + 原图边权重1.8s) = 31.8s,但是当我们开始搜路就会发现一个问题,如果我在原图中指定的起始状态是一个节点而不是一条有向边,比如指定起始节点7而不是起始方向7-1,如何将原图的起始状态映射到对偶图中呢?
如果不做额外处理是无法映射的,若原图中的起始状态是节点7,那么我们需要在对偶节点7-1、对偶节点7-6的上游添加一个临时节点-1,同时生成两条临时边(-1, 7-1)和(-1, 7-6),将原图中的节点7映射为对偶图中的节点-1即可。
对偶图转化实现
import networkx as nx
import pandas as pd
# 起终点状态是有向边的路径搜索主函数
def search_path_by_dual_graph(edge_list=None, source_link=None, target_link=None):
dual_graph, link_node_map_dict, edge_df = convert_dual_graph(edge_list=edge_list)
link_node_map_dict_reverse = {v: k for k, v in link_node_map_dict.items()}
# print(link_node_map_dict_reverse)
# 搜路
try:
dual_node_path = nx.dijkstra_path(dual_graph, link_node_map_dict[source_link], link_node_map_dict[target_link])
print(dual_node_path)
origin_link_path = [link_node_map_dict_reverse[dual_node] for dual_node in dual_node_path]
print(origin_link_path)
except:
print('NO PATH!')
# dig = nx.DiGraph()
# test_edge_list = [(item[0], item[1], {'weight': item[2]}) for item in edge_list]
# print(test_edge_list)
# dig.add_edges_from(test_edge_list)
# print(nx.dijkstra_path(dig, source_link[0], target_link[1]))
pass
# 基于原图建立对偶图
def convert_dual_graph(edge_list=None):
# 建立ft映射哈希表
# {'from': {1: [3, 4], 2: [6, 7, 12, 120]}, 'to': {12: [11, 14], 15: [12, 111]}}
# from下: 1: [3, 4]代表1的直接下游节点有3、4, to下: 12: [11, 14]代表12的直接上游节点有11、14
# 基于原图的特征建立映射关系
edge_df = pd.DataFrame(edge_list, columns=['from', 'to', 'weight'])
ft_map = {'from': edge_df.groupby('from')['to'].apply(list).to_dict(),
'to': edge_df.groupby('to')['from'].apply(list).to_dict()}
edge_df.set_index(['from', 'to'], inplace=True)
# 非对偶图link到对偶图node的映射表
link_node_map_dict = {}
# 初始化对偶图的节点编号为1
dual_node_id = 1
dual_edge_list = []
# 遍历原图中的每一条edge
for _edge in edge_list:
_from_node = _edge[0]
_to_node = _edge[1]
# 为对偶图的分配节点编号
if (_from_node, _to_node) in link_node_map_dict.keys():
pass
else:
print(f'为原边{(_from_node, _to_node)}分配对偶图中的节点编号{dual_node_id}...')
link_node_map_dict[(_from_node, _to_node)] = dual_node_id
# 建立对偶图中边的关系
def judge_no_key(map_dict, key):
if key in map_dict.keys():
return map_dict[key]
else:
return []
# 从原图中找出所有从_to_node出发的边
dual_to_node_list = [(_to_node, x) for x in judge_no_key(ft_map['from'], _to_node)]
# 禁止掉头
if (_to_node, _from_node) in dual_to_node_list:
dual_to_node_list.remove((_to_node, _from_node))
# 映射到对偶图的节点
print(f'原图边{(_from_node, _to_node)}可以到达{dual_to_node_list}')
print([((_from_node, _to_node), _) for _ in dual_to_node_list])
dual_edge_list += [((_from_node, _to_node), _) for _ in dual_to_node_list]
dual_node_id += 1
print('******')
# print(dual_edge_list)
dual_node_list = list(link_node_map_dict.values())
dual_edge_list = [(link_node_map_dict[item[0]], link_node_map_dict[item[1]], {'weight': edge_df.at[item[1], 'weight']}) for item in dual_edge_list]
dual_graph = nx.DiGraph()
dual_graph.add_nodes_from(dual_node_list)
dual_graph.add_edges_from(dual_edge_list)
return dual_graph, link_node_map_dict, edge_df
if __name__ == '__main__':
search_path_by_dual_graph(edge_list=
[(1, 3, 1.90), (3, 1, 1.90), (3, 4, 1.20), (4, 3, 1.20),
(2, 3, 1.239), (3, 2, 1.239), (3, 5, 2.34), (5, 3, 2.34),
(5, 6, 1.30), (2, 7, 0.239), (7, 2, 0.239), (7, 6, 0.1), (6, 7, 0.1)],
source_link=(4, 3), target_link=(3, 2))