考虑路口转向延误的路径搜索

245 阅读10分钟

项目背景

在做某城市的大数据平台时,其中一个模块是做交通模型,为了将模型做的更精细,我们考虑在重要交叉口处考虑转向延误,在之前的一些城市路网模型中,一个交叉口可能只是被简单抽象成下图的样子:

普通路口.jpg

如果我们要考虑link33(4-1方向)到link22(1-3方向)的左转延误,该如何考虑呢,基于目前的最短路算法,必须要提供一条边用于表示左转的时间权重,但是目前这种路网的表达形式却是没有具体的转向边的,那么就有三种方案:改写底层的最短路算法专门处理转向延误、扩展路口、利用对偶图理论进行路网转换。这里简单介绍一下扩展路口和利用对偶图这两种做法。

扩展路口

扩展简介

扩展路口这种做法就是从改造路网本身的拓扑结构出发,扩展出路口的左转、右转、直行以及掉头车道,扩展后的路网如下图所示(考虑掉头的扩展)。这种考虑掉头的扩展得到的每一条扩展车道都是单向车道,因此我们可以直接赋予扩展车道对应的时间延误值,然后组网即可进行路径搜索。

考虑掉头扩展.jpg

如果不考虑掉头,那么可以简化为下图:

不考虑掉头扩展.jpg

由于不考虑掉头,则不需要在节点6、7、8、9处分裂出两条单行道。只需要生成四条左右转双向道路即可(link55、link66、link77、link88),再生成两条直行专用道link89、link90

扩展实现思路

假设我们得到的输入是为扩展前的简单路口,类似上面第一张图,当然他也可能是三叉路口、五岔路口甚至N岔路口,我们如何将扩展的流程进行梳理呢?

以上图简单路口为例,不考虑掉头的扩展方法,假设道路都是双向通行的,即dir=0:

  1. 首先要先分析每一条进口道可以联通的出口道,进口道有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};

  2. 接着,毫无疑问,交叉口节点1肯定是要被删除的,删除后,与其关联的link分别生成一个新节点(6\7\8\9),四条新的link:2-6(双向),5-7(双向),4-8(双向),3-9(双向),此时的路网联通关系我们称之为temp_connect。

  3. 生成新的节点后我们要分析新生成的节点之间该如何连接,这就需要用到之前的连通关系,分析节点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)

测试案例简单可视化:

程序扩展结果展示.jpg

完整工程见GitHub:

github.com/zdsjjtTLG/b…

对偶图

对偶图思路就是将原图的边转化为节点,原图的节点转化为边,依据原图的联通关系构造出对偶图的联通关系。

对偶图原图.jpg

考虑上图的网络(黑色数字是边权重,正反向权重一致),原图有7条双向link,相当于有14条有向边,即在对偶图中有14个节点,原边7-1(新节点7-1)与原边1-3(新节点1-3)联通,对应对偶图中的新有向边 (新节点7-1, 新节点1-3),依次类推,可以得到对偶图如下:

对偶图.jpg

对偶图的节点权重为:该对偶节点在原图中对应的边的权重,对偶图的的有向边权重为:下游对偶节点在原图中对应的有向边的权重,对偶节点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))