Skip to main content
`Ctrl`+`K`

# Source code for aequilibrae.transit.transit_graph_builder

    """Create the graph used by public transport assignment algorithms.

    Naming Conventions:
    - a_node/b_node is head/tail vertex

    TransitGraphBuilder Assumptions:
    - opposite directions are not supported. In the GTFS files, this corresponds to direction_id from trips.txt (indicates the direction of travel for a trip),
    - all times are expressed in seconds [s], all frequencies in [1/s], and
    - headways are uniform for trips of the same pattern.

    """

    import warnings

    import numpy as np
    import pandas as pd
    import pyproj
    import shapely
    import shapely.ops
    import json
    from aequilibrae.utils.geo_utils import haversine
    from aequilibrae.project.database_connection import database_connection
    from scipy.spatial import KDTree, minkowski_distance
    from shapely.geometry import Point

    from aequilibrae.paths import PathResults
    from aequilibrae.context import get_active_project
    from aequilibrae.paths import TransitGraph

    SF_VERTEX_COLS = ["node_id", "node_type", "stop_id", "line_id", "line_seg_idx", "taz_id", "geometry"]
    SF_EDGE_COLS = [
        "link_id",
        "link_type",
        "line_id",
        "stop_id",
        "line_seg_idx",
        "b_node",
        "a_node",
        "trav_time",
        "freq",
        "o_line_id",
        "d_line_id",
        "direction",
    ]

    def shift_duplicate_geometry(df, shift=0.00001):
        """Shift stacked geometry by some fraction of ``shift`` vertically."""

        def _shift_points(group_df, shift):
            points = shapely.from_wkb(group_df.geometry.values)
            count = len(points)
            for i, x in enumerate(points):
                points[i] = shapely.Point(x.x, x.y + (i + 1) * shift / count)

            group_df.geometry = shapely.to_wkb(points)
            return group_df

        return df.groupby(by="geometry", group_keys=False).apply(_shift_points, shift)

    class TransitGraphBuilder:
        """Graph builder for the transit assignment Spiess & Florian algorithm.

        :Arguments:
            **public_transport_conn** (:obj:`sqlite3.Connection`): Connection to the ``public_transport.sqlite`` database.

            **period_id** (:obj:`int`): Period id for the period to be used. Preferred over start and end.

            **time_margin** (:obj:`int`): Time margin, extends the ``start`` and ``end`` times by ``time_margin`` ([``start``, ``end``] becomes [``start`` - ``time_margin``, ``end`` + ``time_margin``]), in order to include more trips when computing mean values. Defaults to ``0``.

            **projected_crs** (:obj:`str`): Projected CRS of the network, intended for more accurate distance calculations. Defaults to ``"EPSG:3857"``, Spherical Mercator.

            **num_threads** (:obj:`int`): Number of threads to be used where possible. Defaults to ``-1``, using all available threads.

            **seed** (:obj:`int`): Seed for ``self.rng``. Defaults to ``124``.

            **geometry_noise** (:obj:`bool`): Whether to use noise in geometry creation, in order to avoid colocated nodes. Defaults to ``True``.

            **noise_coef** (:obj:`float`): Scaling factor of the noise. Defaults to ``1.0e-5``.

            **with_inner_stop_transfers** (:obj:`bool`): Whether to create transfer edges within parent stations. Defaults to ``False``.

            **with_outer_stop_transfers** (:obj:`bool`): Whether to create transfer edges between parent stations. Defaults to ``False``.

            **with_walking_edges** (:obj:`bool`): Whether to create walking edges between distinct stops of each station. Defaults to ``True``.

            **distance_upper_bound** (:obj:`float`): Upper bound on connector distance. Defaults to ``np.inf``.

            **blocking_centroid_flows** (:obj:`bool`): Whether to block flow through centroids. Defaults to ``True``.

            **max_connectors_per_zone** (:obj:`int`): Maximum connectors per zone. Defaults to ``-1`` for unlimited.
        """

        def __init__(
            self,
            public_transport_conn,
            period_id: int = 1,
            time_margin: int = 0,
            projected_crs: str = "EPSG:3857",
            num_threads: int = -1,
            seed: int = 124,
            geometry_noise: bool = True,
            noise_coef: float = 1.0e-5,
            with_inner_stop_transfers: bool = False,
            with_outer_stop_transfers: bool = False,
            with_walking_edges: bool = True,
            distance_upper_bound: float = np.inf,
            blocking_centroid_flows: bool = True,
            connector_method: str = "nearest_neighbour",
            max_connectors_per_zone: int = -1,
        ):
            self.pt_conn = public_transport_conn  # sqlite connection
            self.pt_conn.enable_load_extension(True)
            self.pt_conn.load_extension("mod_spatialite")

            self.project_conn = database_connection("project_database")

            self.period_id = period_id
            start, end = self.project_conn.execute(
                "SELECT period_start, period_end FROM periods WHERE period_id = ?;", [period_id]
            ).fetchall()[0]

            self.start = start - time_margin  # starting time of the selected time period
            self.end = end + time_margin  # ending time of the selected time period
            self.num_threads = num_threads

            # graph components
            # ----------------
            self.vertices = None
            self.edges = None
            self.__line_segments = None
            self.od_node_mapping = None

            # vertices
            self.__stop_vertices = None
            self.__boarding_vertices = None
            self.__alighting_vertices = None
            self.__od_vertices = None

            # edges
            self.__on_board_edges = None
            self.__dwell_edges = None
            self.__alighting_edges = None
            self.__boarding_edges = None
            self.__connector_edges = None
            self.__inner_stop_transfer_edges = pd.DataFrame()
            self.__outer_stop_transfer_edges = pd.DataFrame()
            self.__walking_edges = pd.DataFrame()

            self.global_crs = "EPSG:4326"
            self.__global_crs = pyproj.CRS(self.global_crs)
            self.projected_crs = projected_crs
            self.__projected_crs = pyproj.CRS(self.projected_crs)

            # longlat to projected CRS transformer
            self.transformer_g_to_p = pyproj.Transformer.from_crs(
                self.__global_crs, self.__projected_crs, always_xy=True
            ).transform

            self.transformer_p_to_g = pyproj.Transformer.from_crs(
                self.__projected_crs, self.__global_crs, always_xy=True
            ).transform

            # Add some spatial noise so that stop, boarding and aligthing vertices
            # are not colocated
            self.seed = seed
            self.rng = np.random.default_rng(seed=self.seed)
            self.geometry_noise = geometry_noise
            self.noise_coef = noise_coef

            # graph parameters
            self.uniform_dwell_time = 30
            self.alighting_penalty = 480
            self.a_tiny_time_duration = 1.0e-06
            self.wait_time_factor = 1.0
            self.walk_time_factor = 1.0
            self.walking_speed = 1.0
            self.access_time_factor = 1.0
            self.egress_time_factor = 1.0
            self.with_inner_stop_transfers = with_inner_stop_transfers
            self.with_outer_stop_transfers = with_outer_stop_transfers
            self.with_walking_edges = with_walking_edges
            self.distance_upper_bound = distance_upper_bound
            self.blocking_centroid_flows = blocking_centroid_flows
            self.connector_method = connector_method
            self.max_connectors_per_zone = max_connectors_per_zone

            self.__config_attrs = [
                "period_id",
                "projected_crs",
                "seed",
                "geometry_noise",
                "noise_coef",
                # "uniform_dwell_time",
                # "alighting_penalty",
                # "a_tiny_time_duration",
                # "wait_time_factor",
                # "walk_time_factor",
                # "walking_speed",
                # "access_time_factor",
                # "egress_time_factor",
                "with_inner_stop_transfers",
                "with_outer_stop_transfers",
                "with_walking_edges",
                "distance_upper_bound",
                "blocking_centroid_flows",
                "connector_method",
                "max_connectors_per_zone",
            ]

        def add_zones(self, zones, from_crs: str = None):
            """Add zones as ODs.

            :Arguments:
                **zones** (:obj:`pd.DataFrame`): DataFrame containing the zoning information. Columns must include ``zone_id`` and ``geometry``.

                **from_crs** (:obj:`str`): The CRS of the ``geometry`` column of ``zones``. If not provided it's assumed that the geometry is already in ``self.projected_crs``. If provided, the geometry will be projected to ``self.projected_crs``. Defaults to ``None``.
            """
            if "zone_id" not in zones.columns or "geometry" not in zones.columns:
                raise KeyError("zone_id and geometry must be columns in zones")

            if zones.geometry.dtype is str or zones.geometry.dtype is bytes:
                geometry = shapely.from_wkt(zones.geometry.values)
            # Check if the supplied zones df is from geopandas without import geopandas.
            # We check __mro__ incase of inheritance. https://stackoverflow.com/a/63337375/14047443
            elif "GeometryDtype" in [t.__name__ for t in type(zones.geometry.dtype).__mro__] or all(
                isinstance(x, shapely.geometry.base.BaseGeometry) for x in zones.geometry
            ):
                geometry = zones.geometry.values
            else:
                raise TypeError("geometry is not a string, bytes, or shapely.Geometry instance")

            if from_crs:
                transformer = pyproj.Transformer.from_crs(
                    pyproj.CRS(from_crs), self.__projected_crs, always_xy=True
                ).transform
            else:
                transformer = self.transformer_g_to_p
            geometry = [shapely.ops.transform(transformer, p) for p in geometry]
            centroids = shapely.centroid(geometry)

            self.zones = pd.DataFrame(
                {
                    "zone_id": zones.zone_id.copy(deep=True).astype(str),
                    "geometry": shapely.to_wkb([shapely.ops.transform(self.transformer_p_to_g, p) for p in geometry]),
                    "centroids": shapely.to_wkb([shapely.ops.transform(self.transformer_p_to_g, p) for p in centroids]),
                }
            )

        def _create_line_segments(self):
            """Line segments correspond to segments between two successive stops for each line.

            For example if 2 lines, L1 and L2, are going from stop A to stop B, we have 2 line segments:
            - L1_AB
            - L2_AB

            line_segments table format:
                pattern_id  seq    from_stop      to_stop shortname         line_id  trav_time  headway      freq
            0  10001006000    0  10000000464  10000000462        T2  T2_10001006000      150.0    240.0  0.004167
            1  10001006000    1  10000000462  10000000459        T2  T2_10001006000      110.0    240.0  0.004167
            2  10001006000    2  10000000459  10000000160        T2  T2_10001006000      100.0    240.0  0.004167
            """

            # we select route links for the pattern_ids in the given time range
            sql = f"""SELECT distinct
                trips.pattern_id,
                route_links.seq,
                CAST(from_stop AS TEXT) from_stop,
                CAST(to_stop AS TEXT) to_stop
            FROM
                route_links
            INNER JOIN trips ON route_links.pattern_id = trips.pattern_id
            INNER JOIN trips_schedule ON trips.trip_id = trips_schedule.trip_id
            WHERE
                departure>={self.start}
                AND arrival<={self.end}"""
            route_links = pd.read_sql(
                sql=sql,
                con=self.pt_conn,
            )

            # create a routes table
            sql = "SELECT pattern_id, CAST(shortname AS TEXT) shortname FROM routes"
            routes = pd.read_sql(
                sql=sql,
                con=self.pt_conn,
            )
            # we create a line id by concatenating the route short name with the pattern_id
            routes["line_id"] = routes["shortname"] + "_" + routes["pattern_id"].astype(str)

            # we create the line segments by merging the route links with the routes
            # in order to have a route name on each segment
            self.__line_segments = pd.merge(route_links, routes, on="pattern_id", how="left")

            # we add the travel time and headway to each line segment
            self._add_mean_travel_time_to_segments()
            self._add_mean_headway_to_segments()

            # we compute the frequency from the headway
            self.__line_segments["freq"] = np.inf
            self.__line_segments.loc[self.__line_segments.headway > 0.0, "freq"] = (
                1.0 / self.__line_segments.loc[self.__line_segments.headway > 0.0, "headway"]
            )

        def _compute_segment_travel_time(self, time_filter=True):
            """Compute the mean travel time for each line segment.

            tt table format:
                pattern_id  seq     trav_time
            0   10001006000     1   114.545455
            1   10001006000     2   100.000000
            2   10001006000     3   180.000000

            :Arguments:
               **time_filter** (:obj:`bool`): If time_filter is True, the mean travel time is computed over the
               [start, end] time range, otherwise it is computed over all the available data (e.g. a whole day).
               Defaults to ``True``.

            :Returns:
               **tt** (:obj:`pd.DataFrame`): DataFrame containing the travel item for line segments.
            """

            if time_filter:
                sql = f"""SELECT trips_schedule.trip_id, trips_schedule.seq, trips_schedule.arrival,
                    trips_schedule.departure, trips.pattern_id FROM trips_schedule LEFT JOIN trips
                    ON trips_schedule.trip_id = trips.trip_id
                    WHERE trips_schedule.departure>={self.start} AND trips_schedule.arrival<={self.end}"""
            else:
                sql = """SELECT trips_schedule.trip_id, trips_schedule.seq, trips_schedule.arrival,
                    trips_schedule.departure, trips.pattern_id FROM trips_schedule LEFT JOIN trips
                    ON trips_schedule.trip_id = trips.trip_id"""
            tt = pd.read_sql(sql=sql, con=self.pt_conn)

            # compute the travel time on the segments
            tt.sort_values(by=["pattern_id", "trip_id", "seq"], ascending=True, inplace=True)
            tt["last_departure"] = tt["departure"].shift(+1)
            tt["last_trip_id"] = tt["trip_id"].shift(+1)
            tt["last_pattern_id"] = tt["pattern_id"].shift(+1)
            tt["trav_time"] = tt["arrival"] - tt["last_departure"]
            tt.dropna(how="any", inplace=True)
            tt[["last_departure", "last_trip_id", "last_pattern_id"]] = tt[
                ["last_departure", "last_trip_id", "last_pattern_id"]
            ].astype(int)

            tt.loc[(tt.last_pattern_id != tt.pattern_id) | (tt.last_trip_id != tt.trip_id), "trav_time"] = np.nan
            tt.dropna(subset="trav_time", inplace=True)
            tt = tt.copy(deep=True)

            # tt.seq refers to the stop sequence index.
            # Because we computed the travel time between two stops, we are now dealing
            # with a segment sequence index.
            tt = tt.loc[tt.seq > 0]
            tt.seq -= 1

            # take the mean of the travel times computed among the trips of a pattern segment
            tt = tt[["pattern_id", "seq", "trav_time"]].groupby(["pattern_id", "seq"]).mean().reset_index(drop=False)

            return tt

        def _add_mean_travel_time_to_segments(self):
            """Add a column to the line segment table with a mean travel time.

            Because there might be missing values when computing the travel time on a small time range,
            we also compute the mean travel time over all the available data in order to fill these potential
            missing values.
            """
            tt = self._compute_segment_travel_time(time_filter=True)
            tt_full = self._compute_segment_travel_time(time_filter=False)
            tt_full.rename(columns={"trav_time": "trav_time_full"}, inplace=True)

            # Compute the mean travel time from the different trips corresponding to
            self.__line_segments = pd.merge(self.__line_segments, tt, on=["pattern_id", "seq"], how="left")
            self.__line_segments = pd.merge(self.__line_segments, tt_full, on=["pattern_id", "seq"], how="left")
            self.__line_segments.trav_time = self.__line_segments.trav_time.fillna(self.__line_segments.trav_time_full)
            self.__line_segments.drop("trav_time_full", axis=1, inplace=True)
            self.__line_segments.trav_time = self.__line_segments.trav_time.fillna(self.end - self.start)

        def _add_mean_headway_to_segments(self):
            """Compute the mean headway for each pattern and add it to the line segment table, as headway.

            When there is not enough information to compute the mean headway (e.g. a single trip), the headway is
            given the value of the time range length.
            """
            # start from the trip_schedule table
            sql = f"""SELECT trip_id, seq, arrival FROM trips_schedule
                WHERE departure>={self.start} AND arrival<={self.end}"""
            mh = pd.read_sql(sql=sql, con=self.pt_conn)

            # merge the trips schedules with pattern ids
            trips = pd.read_sql(sql="SELECT trip_id, pattern_id FROM trips", con=self.pt_conn)
            mh = pd.merge(mh, trips, on="trip_id", how="left")
            mh.sort_values(by=["pattern_id", "seq", "trip_id", "arrival"], inplace=True)
            mh["headway"] = mh["arrival"].diff()

            # count the number of trips per stop
            trip_count = mh.groupby(["pattern_id", "seq"]).size().to_frame("trip_count")
            mh = pd.merge(mh, trip_count, on=["pattern_id", "seq"], how="left")

            # compute the trip index for a given couple pattern & stop
            trip_id_last = -1
            seq_last = -1
            pattern_id_last = -1
            trip_idx_values = np.zeros(len(mh), dtype=int)
            trip_idx = 0
            i = 0
            for row in mh.itertuples():
                trip_id = row.trip_id
                seq = row.seq
                pattern_id = row.seq
                assert (trip_id != trip_id_last) | (seq == seq_last + 1)

                if seq != seq_last:
                    trip_idx = 0
                if pattern_id != pattern_id_last:
                    trip_idx = 0

                trip_id_last = trip_id
                seq_last = seq
                pattern_id_last = pattern_id

                trip_idx_values[i] = trip_idx
                i += 1
                trip_idx += 1
            mh["trip_idx"] = trip_idx_values

            # deal with single trip case for a given stop
            largest_headway = self.end - self.start
            mh.loc[mh["trip_count"] == 1, "headway"] = largest_headway

            # deal with first trip for a stop & pattern
            mh.loc[(mh["trip_count"] > 1) & (mh["trip_idx"] == 0), "headway"] = np.nan
            mh["headway"] = mh["headway"].bfill()

            # take the min of the headways computed among the stops of a given trip
            mh = mh[["pattern_id", "trip_id", "headway"]].groupby("pattern_id").min().reset_index(drop=False)

            # compute the mean headway computed among the trips of a given pattern
            mh = mh[["pattern_id", "headway"]].groupby("pattern_id").mean().reset_index(drop=False)

            self.__line_segments = pd.merge(self.__line_segments, mh, on=["pattern_id"], how="left")

        def _create_stop_vertices(self):
            """Create stop vertices."""
            # select all stops
            sql = "SELECT CAST(stop_id AS TEXT) stop_id, ST_AsBinary(geometry) AS geometry FROM stops"
            stop_vertices = pd.read_sql(sql=sql, con=self.pt_conn)

            # filter stops that are used on the given time range
            stops_ids = pd.concat((self.__line_segments.from_stop, self.__line_segments.to_stop), axis=0).unique()
            stop_vertices = stop_vertices.loc[stop_vertices.stop_id.isin(stops_ids)]

            # uniform attributes
            stop_vertices["line_seg_idx"] = -1
            stop_vertices["node_type"] = "stop"

            self.__stop_vertices = stop_vertices

        def _create_boarding_vertices(self):
            """Create boarding vertices with noise, if enabled."""
            boarding_vertices = self.__line_segments[["line_id", "seq", "from_stop"]].copy(deep=True)
            boarding_vertices.rename(columns={"seq": "line_seg_idx", "from_stop": "stop_id"}, inplace=True)
            boarding_vertices = pd.merge(
                boarding_vertices, self.__stop_vertices[["stop_id", "geometry"]], on="stop_id", how="left"
            )

            # uniform attributes
            boarding_vertices["node_type"] = "boarding"

            # add noise
            if self.geometry_noise:
                boarding_vertices["x"] = boarding_vertices.geometry.map(lambda c: shapely.wkb.loads(c).x)
                boarding_vertices["y"] = boarding_vertices.geometry.map(lambda c: shapely.wkb.loads(c).y)
                n_boarding = len(boarding_vertices)
                boarding_vertices["x"] += self.noise_coef * (self.rng.random(n_boarding) - 0.5)
                boarding_vertices["y"] += self.noise_coef * (self.rng.random(n_boarding) - 0.5)
                boarding_vertices["geometry"] = boarding_vertices.apply(lambda row: Point(row.x, row.y).wkb, axis=1)
                boarding_vertices.drop(["x", "y"], axis=1, inplace=True)

            self.__boarding_vertices = boarding_vertices

        def _create_alighting_vertices(self):
            """Create alighting vertices with noise, if enabled."""
            alighting_vertices = self.__line_segments[["line_id", "seq", "to_stop"]].copy(deep=True)
            alighting_vertices.rename(columns={"seq": "line_seg_idx", "to_stop": "stop_id"}, inplace=True)
            alighting_vertices = pd.merge(
                alighting_vertices, self.__stop_vertices[["stop_id", "geometry"]], on="stop_id", how="left"
            )

            # uniform attributes
            alighting_vertices["node_type"] = "alighting"

            # add noise
            if self.geometry_noise:
                alighting_vertices["x"] = alighting_vertices.geometry.map(lambda c: shapely.wkb.loads(c).x)
                alighting_vertices["y"] = alighting_vertices.geometry.map(lambda c: shapely.wkb.loads(c).y)
                n_alighting = len(alighting_vertices)
                alighting_vertices["x"] += self.noise_coef * (self.rng.random(n_alighting) - 0.5)
                alighting_vertices["y"] += self.noise_coef * (self.rng.random(n_alighting) - 0.5)
                alighting_vertices["geometry"] = alighting_vertices.apply(lambda row: Point(row.x, row.y).wkb, axis=1)
                alighting_vertices.drop(["x", "y"], axis=1, inplace=True)

            self.__alighting_vertices = alighting_vertices

        def _create_od_vertices(self):
            """Create OD vertices from zones.

            If zones have not previously been added, add zones from the project.
            If ``self.blocking_centroid_flow`` is ``True``, a distinction is made between ``origin`` and ``destination`` vertices. Otherwise, they are both classified as ``od``.
            """
            if "zones" not in self.__dict__:
                project = get_active_project(True)
                self.add_zones(
                    pd.DataFrame(
                        [(x.zone_id, x.geometry) for x in project.zoning.all_zones().values()],
                        columns=["zone_id", "geometry"],
                    )
                )

            if self.blocking_centroid_flows:
                # we create both "origin" and "destination" nodes
                origin_vertices = self.zones[["zone_id", "centroids"]].rename(
                    columns={"zone_id": "taz_id", "centroids": "geometry"}
                )

                # uniform attributes
                origin_vertices["node_type"] = "origin"
                origin_vertices["line_seg_idx"] = -1

                destination_vertices = origin_vertices.copy(deep=True)
                destination_vertices["node_type"] = "destination"

                od_vertices = pd.concat((origin_vertices, destination_vertices), axis=0)
            else:
                # we create only "od" nodes
                od_vertices = self.zones[["zone_id", "centroids"]].rename(
                    columns={"zone_id": "taz_id", "centroids": "geometry"}
                )

                # uniform attributes
                od_vertices["node_type"] = "od"
                od_vertices["line_seg_idx"] = -1

            self.__od_vertices = od_vertices

        def create_od_node_mapping(self):
            """Build a dataframe mapping the centroid node ids with to transport assignment zone ids."""
            if self.blocking_centroid_flows:
                origin_nodes = self.vertices.loc[
                    self.vertices.node_type == "origin",
                    ["node_id", "taz_id"],
                ]
                origin_nodes.rename(columns={"node_id": "o_node_id"}, inplace=True)
                destination_nodes = self.vertices.loc[
                    self.vertices.node_type == "destination",
                    ["node_id", "taz_id"],
                ]
                destination_nodes.rename(columns={"node_id": "d_node_id"}, inplace=True)
                od_node_mapping = pd.merge(origin_nodes, destination_nodes, on="taz_id", how="left")[
                    ["o_node_id", "d_node_id", "taz_id"]
                ]
            else:
                od_node_mapping = self.vertices.loc[
                    self.vertices.node_type == "od",
                    ["node_id", "taz_id"],
                ]
            self.od_node_mapping = od_node_mapping

        def _create_vertices(self):
            """Graph vertices creation as a dataframe.

            Vertices have the following attributes:
                - node_id (:obj:`int`),
                - node_type (:obj:`str`): Either 'stop', 'boarding', 'alighting', 'od', 'origin', or 'destination',
                - stop_id (:obj:`str`): Only applies to 'stop', 'boarding' and 'alighting' vertices,
                - line_id (:obj:`str`): Only applies to 'boarding' and 'alighting' vertices,
                - line_seg_idx (:obj:`int`): Only applies to 'boarding' and 'alighting' vertices,
                - taz_id (:obj:`str`): Only applies to 'origin', 'destination', and 'od' nodes,
                - geometry (:obj:`str`): Geometry object in WKB (well known binary).
            """
            self._create_line_segments()
            self._create_stop_vertices()
            self._create_boarding_vertices()
            self._create_alighting_vertices()
            self._create_od_vertices()

            # stack the dataframes on top of each other
            self.vertices = pd.concat(
                [
                    self.__od_vertices,
                    self.__stop_vertices,
                    self.__boarding_vertices,
                    self.__alighting_vertices,
                ],
                axis=0,
            )

            # reset index and copy it to column
            self.vertices.reset_index(drop=True, inplace=True)
            self.vertices.index.name = "index"
            self.vertices["node_id"] = self.vertices.index + 1
            self.vertices = self.vertices[SF_VERTEX_COLS]
            self.create_od_node_mapping()

            # data types
            self.vertices.node_id = self.vertices.node_id.astype(int)
            self.vertices["node_type"] = self.vertices["node_type"].astype("category")
            self.vertices.stop_id = self.vertices.stop_id.fillna("").astype(str)
            self.vertices.line_id = self.vertices.line_id.fillna("").astype(str)
            self.vertices.line_seg_idx = self.vertices.line_seg_idx.fillna(-1).astype("int32")
            self.vertices.taz_id = self.vertices.taz_id.fillna("").astype(str)

        def _create_on_board_edges(self):
            """Create on board edges."""
            on_board_edges = self.__line_segments[["line_id", "seq", "trav_time"]].copy(deep=True)
            on_board_edges.rename(columns={"seq": "line_seg_idx"}, inplace=True)

            # get tail vertex index
            on_board_edges = pd.merge(
                on_board_edges,
                self.vertices[self.vertices.node_type == "boarding"][["line_id", "line_seg_idx", "node_id"]],
                on=["line_id", "line_seg_idx"],
                how="left",
            )
            on_board_edges.rename(columns={"node_id": "b_node"}, inplace=True)

            # get head vertex index
            on_board_edges = pd.merge(
                on_board_edges,
                self.vertices[self.vertices.node_type == "alighting"][["line_id", "line_seg_idx", "node_id"]],
                on=["line_id", "line_seg_idx"],
                how="left",
            )
            on_board_edges.rename(columns={"node_id": "a_node"}, inplace=True)

            # uniform attributes
            on_board_edges["link_type"] = "on-board"
            on_board_edges["freq"] = np.inf
            on_board_edges["direction"] = 1

            self.__on_board_edges = on_board_edges

        def _create_boarding_edges(self):
            """Create boarding edges."""
            boarding_edges = self.__line_segments[["line_id", "seq", "from_stop", "freq"]].copy(deep=True)
            boarding_edges.rename(columns={"seq": "line_seg_idx", "from_stop": "stop_id"}, inplace=True)

            # get tail vertex index (stop vertex)
            boarding_edges = pd.merge(
                boarding_edges,
                self.vertices[self.vertices.node_type == "stop"][["stop_id", "node_id"]],
                on="stop_id",
                how="left",
            )
            boarding_edges.rename(columns={"node_id": "b_node"}, inplace=True)

            # get head vertex index (boarding vertex)
            boarding_edges = pd.merge(
                boarding_edges,
                self.vertices[self.vertices.node_type == "boarding"][["line_id", "line_seg_idx", "node_id"]],
                on=["line_id", "line_seg_idx"],
                how="left",
            )
            boarding_edges.rename(columns={"node_id": "a_node"}, inplace=True)

            # frequency update : line_freq / wait_time_factor
            wait_time_factor_inv = 1.0 / self.wait_time_factor
            boarding_edges["freq"] *= wait_time_factor_inv

            # uniform attributes
            boarding_edges["link_type"] = "boarding"
            boarding_edges["trav_time"] = 0.5 * self.uniform_dwell_time + self.a_tiny_time_duration
            boarding_edges["direction"] = 1

            self.__boarding_edges = boarding_edges

        def _create_alighting_edges(self):
            """Create alighting edges."""
            alighting_edges = self.__line_segments[["line_id", "seq", "to_stop"]].copy(deep=True)
            alighting_edges.rename(columns={"seq": "line_seg_idx", "to_stop": "stop_id"}, inplace=True)

            # get tail vertex index (alighting vertex)
            alighting_edges = pd.merge(
                alighting_edges,
                self.vertices[self.vertices.node_type == "alighting"][["line_id", "line_seg_idx", "node_id"]],
                on=["line_id", "line_seg_idx"],
                how="left",
            )
            alighting_edges.rename(columns={"node_id": "b_node"}, inplace=True)

            # get head vertex index (stop vertex)
            alighting_edges = pd.merge(
                alighting_edges,
                self.vertices[self.vertices.node_type == "stop"][["stop_id", "node_id"]],
                on="stop_id",
                how="left",
            )
            alighting_edges.rename(columns={"node_id": "a_node"}, inplace=True)

            # uniform attributes
            alighting_edges["link_type"] = "alighting"
            alighting_edges["freq"] = np.inf
            alighting_edges["trav_time"] = (
                0.5 * self.uniform_dwell_time + self.alighting_penalty + self.a_tiny_time_duration
            )
            alighting_edges["direction"] = 1

            self.__alighting_edges = alighting_edges

        def _create_dwell_edges(self):
            """Create dwell edges."""
            # we start by removing the first segment of each line
            dwell_edges = self.__line_segments.loc[self.__line_segments.seq != 0][["line_id", "from_stop", "seq"]]
            dwell_edges.rename(columns={"seq": "line_seg_idx"}, inplace=True)

            # we take the first stop of the segment
            dwell_edges["stop_id"] = dwell_edges.from_stop

            # head vertex index (boarding vertex)
            # boarding vertices of line segments [1:segment_count+1]
            dwell_edges = pd.merge(
                dwell_edges,
                self.vertices[self.vertices.node_type == "boarding"][["line_id", "stop_id", "node_id", "line_seg_idx"]],
                on=["line_id", "stop_id", "line_seg_idx"],
                how="left",
            )
            dwell_edges.rename(columns={"node_id": "a_node"}, inplace=True)

            # tail vertex index (alighting vertex)
            # aligthing vertices of line segments [0:segment_count]
            dwell_edges.line_seg_idx -= 1
            dwell_edges = pd.merge(
                dwell_edges,
                self.vertices[self.vertices.node_type == "alighting"][["line_id", "stop_id", "node_id", "line_seg_idx"]],
                on=["line_id", "stop_id", "line_seg_idx"],
                how="left",
            )
            dwell_edges.rename(columns={"node_id": "b_node"}, inplace=True)

            # clean-up
            dwell_edges.drop("from_stop", axis=1, inplace=True)

            # uniform values
            dwell_edges["line_seg_idx"] = -1
            dwell_edges["link_type"] = "dwell"
            dwell_edges["freq"] = np.inf
            dwell_edges["trav_time"] = self.uniform_dwell_time
            dwell_edges["direction"] = 1

            self.__dwell_edges = dwell_edges

        def _create_connector_edges(self, method=None, allow_missing_connections=True):
            """
            Create the connector edges between each stops and ODs.

            Nearest neighbour: Creates edges between every stop and its nearest OD.

            Overlapping regions: Creates edges between all stops that lying within the circle centred on each OD whose radius is the distance to the next nearest OD.

            :Arguments:
               **method** (:obj:`str`): Must either be "overlapping_regions", or "nearest_neighbour".
               Defaults to ``overlapping_regions``.

               **allow_missing_connections** (:obj:`bool`): Whether to allow missing connections or not.
               Defaults to ``True``.
            """
            if method is None:
                method = self.connector_method
            else:
                self.connector_method = method

            if method not in ["overlapping_regions", "nearest_neighbour"]:
                raise ValueError("method must be either 'overlapping_regions' or 'nearest_neighbour'")

            # Create access connectors
            # ========================

            # Select/copy the od vertices and project their geometry
            if self.blocking_centroid_flows:
                node_type = "origin"
            else:
                node_type = "od"
            od_vertices = self.vertices[self.vertices.node_type == node_type][["node_id", "taz_id", "geometry"]].copy(
                deep=True
            )

            od_vertices.reset_index(drop=True, inplace=True)
            od_geometries = od_vertices["geometry"].apply(
                lambda geometry: shapely.ops.transform(self.transformer_g_to_p, shapely.from_wkb(geometry))
            )
            od_geometries = np.array(list(od_geometries.apply(lambda geometry: (geometry.x, geometry.y))))

            # Select/copy the stop vertices and project their geometryinates
            stop_vertices = self.vertices[self.vertices.node_type == "stop"][["node_id", "stop_id", "geometry"]].copy(
                deep=True
            )
            stop_vertices.reset_index(drop=True, inplace=True)
            stop_geometries = stop_vertices["geometry"].apply(
                lambda geometry: shapely.ops.transform(self.transformer_g_to_p, shapely.from_wkb(geometry))
            )
            stop_geometries = np.array(list(stop_geometries.apply(lambda geometry: (geometry.x, geometry.y))))

            kdTree = KDTree(od_geometries)

            if method == "nearest_neighbour":
                # query the kdTree for the closest (k=1) od for each stop in parallel (workers=-1)
                distance, index = kdTree.query(
                    stop_geometries, k=1, distance_upper_bound=self.distance_upper_bound, workers=self.num_threads
                )
                nearest_od = od_vertices.iloc[index][["node_id", "taz_id"]].reset_index(drop=True)
                trav_time = pd.Series(distance / self.walking_speed, name="trav_time")

                # access connectors
                access_connector_edges = pd.concat(
                    [
                        stop_vertices[["stop_id", "node_id"]].reset_index(drop=True).rename(columns={"node_id": "a_node"}),
                        nearest_od.rename(columns={"node_id": "b_node"}),
                        trav_time,
                    ],
                    axis=1,
                )

            elif method == "overlapping_regions":
                # Construct a kdtree so we can lookup the 2nd closest OD to each OD (the first being itself)
                distance, _ = kdTree.query(
                    od_geometries, k=[2], distance_upper_bound=self.distance_upper_bound, workers=self.num_threads
                )
                distance = distance.reshape(-1)

                # Construct a kdtree so we can query all the stops within the radius around each OD
                stop_kdTree = KDTree(stop_geometries)
                results = stop_kdTree.query_ball_point(od_geometries, distance, workers=self.num_threads)

                # access connectors

                # Build up a list of dataframes to concat, each dataframe corresponds to all connectors for a given OD
                connectors = []
                for i, verts in enumerate(results):
                    distance = minkowski_distance(od_geometries[i], stop_geometries[verts])
                    df = stop_vertices["node_id"].iloc[verts].to_frame()
                    df["b_node"] = od_vertices.iloc[i]["node_id"]  # OD is tail node of access connector
                    df["trav_time"] = distance / self.walking_speed
                    connectors.append(df)
                access_connector_edges = pd.concat(connectors).rename(columns={"node_id": "a_node"}).reset_index(drop=True)

                if not allow_missing_connections:
                    # Now we need to build up the edges for the stops without connectors
                    missing = stop_vertices["node_id"].isin(access_connector_edges["a_node"])
                    missing = missing[~missing].index

                    distance, index = kdTree.query(
                        stop_geometries[missing], k=1, distance_upper_bound=np.inf, workers=self.num_threads
                    )
                    nearest_od = od_vertices["node_id"].iloc[index].reset_index(drop=True)
                    trav_time = pd.Series(distance / self.walking_speed, name="trav_time")
                    missing_edges = pd.concat(
                        [
                            stop_vertices["node_id"].iloc[missing].reset_index(drop=True).rename("a_node"),
                            nearest_od.rename("b_node"),
                            trav_time,
                        ],
                        axis=1,
                    )

                    access_connector_edges = pd.concat([access_connector_edges, missing_edges], axis=0)

            # uniform values
            access_connector_edges["link_type"] = "access_connector"
            access_connector_edges["line_seg_idx"] = -1
            access_connector_edges["freq"] = np.inf
            access_connector_edges["direction"] = 1

            if self.max_connectors_per_zone > 0:
                # max_connectors_per_zone connectors per zone with smallest travel time are selected
                access_connector_edges = access_connector_edges.sort_values(["b_node", "trav_time"])
                access_connector_edges = access_connector_edges.groupby("b_node").apply(
                    lambda df: df.head(self.max_connectors_per_zone)
                )

            # Create egress connectors
            # ========================
            egress_connector_edges = access_connector_edges.copy(deep=True)
            egress_connector_edges.rename(columns={"a_node": "b_node", "b_node": "a_node"}, inplace=True)

            if self.blocking_centroid_flows:
                # we need to switch the head node to a destination node instead of an origin node
                egress_connector_edges = pd.merge(
                    egress_connector_edges,
                    self.od_node_mapping[["o_node_id", "d_node_id"]],
                    left_on="a_node",
                    right_on="o_node_id",
                    how="left",
                )
                egress_connector_edges.drop(["a_node", "o_node_id"], axis=1, inplace=True)
                egress_connector_edges.rename(columns={"d_node_id": "a_node"}, inplace=True)

            # uniform values
            egress_connector_edges["link_type"] = "egress_connector"
            egress_connector_edges["line_seg_idx"] = -1
            egress_connector_edges["freq"] = np.inf
            egress_connector_edges["direction"] = 1

            # travel time update
            access_connector_edges.trav_time *= self.access_time_factor
            egress_connector_edges.trav_time *= self.egress_time_factor

            self.__connector_edges = pd.concat([access_connector_edges, egress_connector_edges], axis=0)

        def _create_inner_stop_transfer_edges(self):
            """Create transfer edges between distinct lines of each stop."""
            alighting = self.vertices[self.vertices.node_type == "alighting"][["stop_id", "line_id", "node_id"]].rename(
                columns={"line_id": "o_line_id", "node_id": "b_node"}
            )
            boarding = self.vertices[self.vertices.node_type == "boarding"][["stop_id", "line_id", "node_id"]].rename(
                columns={"line_id": "d_line_id", "node_id": "a_node"}
            )
            inner_stop_transfer_edges = pd.merge(alighting, boarding, on="stop_id", how="inner")

            # remove entries that have the same line as origin and destination
            inner_stop_transfer_edges = inner_stop_transfer_edges.loc[
                inner_stop_transfer_edges["o_line_id"] != inner_stop_transfer_edges["d_line_id"]
            ]

            # update the transfer edge frequency
            inner_stop_transfer_edges = pd.merge(
                inner_stop_transfer_edges,
                self.__line_segments[["from_stop", "line_id", "freq"]],
                left_on=["stop_id", "d_line_id"],
                right_on=["from_stop", "line_id"],
                how="left",
            )
            inner_stop_transfer_edges.drop(["from_stop", "line_id"], axis=1, inplace=True)

            # uniform attributes
            inner_stop_transfer_edges["line_seg_idx"] = -1
            inner_stop_transfer_edges["link_type"] = "inner_transfer"
            inner_stop_transfer_edges["direction"] = 1

            # frequency update : line_freq / wait_time_factor
            wait_time_factor_inv = 1.0 / self.wait_time_factor
            inner_stop_transfer_edges["freq"] *= wait_time_factor_inv

            # travel time update : dwell_time + alighting_penalty
            inner_stop_transfer_edges["trav_time"] = self.uniform_dwell_time + self.alighting_penalty

            # remove duplicates
            inner_stop_transfer_edges.drop_duplicates(inplace=True)

            self.__inner_stop_transfer_edges = inner_stop_transfer_edges

        def _create_outer_stop_transfer_edges(self):
            """Create transfer edges between distinct lines/stops of each station."""
            sql = """
            SELECT CAST(stop_id as TEXT) stop_id, CAST(parent_station as TEXT) parent_station FROM stops
            WHERE parent_station IS NOT NULL AND parent_station <> ''
            """
            stops = pd.read_sql(sql=sql, con=self.pt_conn)
            stations = stops.groupby("parent_station").size().to_frame("stop_count").reset_index(drop=False)

            # we only keep the stations which contain at least 2 stops
            stations = stations[stations["stop_count"] > 1]
            station_list = stations["parent_station"].values
            stops = stops[stops.parent_station.isin(station_list)]

            # load the aligthing vertices (tail of transfer edges)
            alighting = self.vertices[self.vertices.node_type == "alighting"][
                ["stop_id", "line_id", "node_id", "geometry"]
            ].rename(columns={"line_id": "o_line_id", "geometry": "o_geometry", "node_id": "b_node"})
            # add the station id
            alighting = pd.merge(alighting, stops, on="stop_id", how="inner")
            alighting.rename(columns={"stop_id": "o_stop_id"}, inplace=True)

            # load the boarding vertices (head of transfer edges)
            boarding = self.vertices[self.vertices.node_type == "boarding"][
                ["stop_id", "line_id", "node_id", "geometry"]
            ].rename(columns={"line_id": "d_line_id", "geometry": "d_geometry", "node_id": "a_node"})
            # add the station id
            boarding = pd.merge(boarding, stops, on="stop_id", how="inner")
            boarding.rename(columns={"stop_id": "d_stop_id"}, inplace=True)

            outer_stop_transfer_edges = pd.merge(alighting, boarding, on="parent_station", how="inner")
            outer_stop_transfer_edges.drop("parent_station", axis=1, inplace=True)

            outer_stop_transfer_edges.dropna(how="any", inplace=True)

            # remove entries that share the same stop
            outer_stop_transfer_edges = outer_stop_transfer_edges.loc[
                outer_stop_transfer_edges["o_stop_id"] != outer_stop_transfer_edges["d_stop_id"]
            ]

            # remove entries that have the same line as origin and destination
            outer_stop_transfer_edges = outer_stop_transfer_edges.loc[
                outer_stop_transfer_edges["o_line_id"] != outer_stop_transfer_edges["d_line_id"]
            ]

            # update the transfer edge frequency
            outer_stop_transfer_edges = pd.merge(
                outer_stop_transfer_edges,
                self.__line_segments[["from_stop", "line_id", "freq"]],
                left_on=["d_stop_id", "d_line_id"],
                right_on=["from_stop", "line_id"],
                how="left",
            )
            outer_stop_transfer_edges.drop(["o_stop_id", "d_stop_id", "from_stop", "line_id"], axis=1, inplace=True)

            # uniform attributes
            outer_stop_transfer_edges["line_seg_idx"] = -1
            outer_stop_transfer_edges["link_type"] = "outer_transfer"

            # frequency update : line_freq / wait_time_factor
            wait_time_factor_inv = 1.0 / self.wait_time_factor
            outer_stop_transfer_edges["freq"] *= wait_time_factor_inv

            # compute the walking time
            o_geometry = shapely.from_wkb(outer_stop_transfer_edges.o_geometry.values)
            d_geometry = shapely.from_wkb(outer_stop_transfer_edges.d_geometry.values)
            o_lon, o_lat = np.vectorize(lambda x: (x.x, x.y))(o_geometry)
            d_lon, d_lat = np.vectorize(lambda x: (x.x, x.y))(d_geometry)

            distance = haversine(o_lon, o_lat, d_lon, d_lat)

            outer_stop_transfer_edges["trav_time"] = distance / self.walking_speed
            outer_stop_transfer_edges["trav_time"] *= self.walk_time_factor
            outer_stop_transfer_edges["trav_time"] += self.alighting_penalty
            outer_stop_transfer_edges["direction"] = 1

            # cleanup
            outer_stop_transfer_edges.drop(
                ["o_geometry", "d_geometry"],
                axis=1,
                inplace=True,
            )

            # remove duplicates
            outer_stop_transfer_edges.drop_duplicates(inplace=True)

            self.__outer_stop_transfer_edges = outer_stop_transfer_edges

        def _create_walking_edges(self):
            """Create walking edges between distinct stops of each station."""

            sql = "SELECT COUNT(*) FROM stops WHERE parent_station IS NOT NULL AND parent_station <> ''"
            station_count = self.pt_conn.execute(sql).fetchone()[0]

            if station_count > 0:
                sql = """
                SELECT CAST(stop_id AS TEXT) stop_id, CAST(parent_station AS TEXT) parent_station FROM stops
                WHERE parent_station IS NOT NULL AND parent_station <> ''
                """
                stops = pd.read_sql(sql=sql, con=self.pt_conn)

                print(stops)
                stops.drop_duplicates(inplace=True)
                stations = stops.groupby("parent_station").size().to_frame("stop_count").reset_index(drop=False)

                # we only keep the stations which contain at least 2 stops
                stations = stations[stations["stop_count"] > 1]
                station_list = stations["parent_station"].values
                stops = stops[stops.parent_station.isin(station_list)]

                # tail vertex
                o_walking = self.vertices[self.vertices.node_type == "stop"][["stop_id", "node_id", "geometry"]].rename(
                    columns={"geometry": "o_geometry", "node_id": "b_node"}
                )
                o_walking = pd.merge(o_walking, stops, on="stop_id", how="inner")
                o_walking.rename(columns={"stop_id": "o_stop_id"}, inplace=True)

                # head vertex
                d_walking = self.vertices[self.vertices.node_type == "stop"][["stop_id", "node_id", "geometry"]].rename(
                    columns={"geometry": "d_geometry", "node_id": "a_node"}
                )
                d_walking = pd.merge(d_walking, stops, on="stop_id", how="inner")
                d_walking.rename(columns={"stop_id": "d_stop_id"}, inplace=True)

                walking_edges = pd.merge(o_walking, d_walking, on="parent_station", how="inner")

                # remove entries that share the same stop
                walking_edges = walking_edges.loc[walking_edges["o_stop_id"] != walking_edges["d_stop_id"]]
                walking_edges.drop("parent_station", axis=1, inplace=True)

                # uniform attributes
                walking_edges["line_seg_idx"] = -1
                walking_edges["link_type"] = "walking"
                walking_edges["freq"] = np.inf
                walking_edges["direction"] = 1

                # compute the walking time
                o_geometry = shapely.from_wkb(walking_edges.o_geometry.values)
                d_geometry = shapely.from_wkb(walking_edges.d_geometry.values)
                o_lon, o_lat = np.vectorize(lambda x: (x.x, x.y))(o_geometry)
                d_lon, d_lat = np.vectorize(lambda x: (x.x, x.y))(d_geometry)

                distance = haversine(o_lon, o_lat, d_lon, d_lat)

                walking_edges["trav_time"] = distance / self.walking_speed
                walking_edges["trav_time"] *= self.walk_time_factor

                # cleanup
                walking_edges.drop(
                    ["o_geometry", "d_geometry"],
                    axis=1,
                    inplace=True,
                )

                self.__walking_edges = walking_edges

        def _create_edges(self):
            """Graph edges creation as a Dataframe.

            Edges have the following attributes:
                - type (:obj:`str`): Either 'on-board', 'boarding', 'alighting', 'dwell', 'inner_transfer', 'outer_transfer',
                  'access_connector', "egress_connector" or 'walking',
                - line_id (:obj:`str`): Only applies to 'on-board', 'boarding', 'alighting' and 'dwell' edges,
                - stop_id (:obj:`str`),
                - line_seg_idx (:obj:`int`): Only applies to 'on-board', 'boarding' and 'alighting' edges,
                - b_node (:obj:`int`),
                - a_node (:obj:`int`),
                - trav_time (:obj:`float`): Edge travel time,
                - freq (:obj:`float`),
                - o_line_id (:obj:`str`),
                - d_line_id (:obj:`str`),
                - transfer_id (:obj:`str`)
            """
            # create the graph edges
            self._create_on_board_edges()
            self._create_dwell_edges()
            self._create_boarding_edges()
            self._create_alighting_edges()
            self._create_connector_edges()
            if self.with_inner_stop_transfers:
                self._create_inner_stop_transfer_edges()
            if self.with_outer_stop_transfers:
                self._create_outer_stop_transfer_edges()
            if self.with_walking_edges:
                self._create_walking_edges()

            # stack the dataframes on top of each other
            self.edges = pd.concat(
                [
                    self.__on_board_edges,
                    self.__boarding_edges,
                    self.__alighting_edges,
                    self.__dwell_edges,
                    self.__connector_edges,
                    self.__inner_stop_transfer_edges,
                    self.__outer_stop_transfer_edges,
                    self.__walking_edges,
                ],
                axis=0,
            )

            # reset index and copy it to column
            self.edges.reset_index(drop=True, inplace=True)
            self.edges.index.name = "index"
            self.edges["link_id"] = self.edges.index + 1
            for col in SF_EDGE_COLS:
                if col not in self.edges:
                    self.edges[col] = np.nan
            self.edges = self.edges[SF_EDGE_COLS]

            # data types
            self.edges["link_type"] = self.edges["link_type"].astype("category")
            self.edges.line_id = self.edges.line_id.fillna("").astype(str)
            self.edges.stop_id = self.edges.stop_id.fillna("").astype(str)
            self.edges.line_seg_idx = self.edges.line_seg_idx.fillna(-1).astype("int32")
            self.edges.b_node = self.edges.b_node.astype("int32")
            self.edges.a_node = self.edges.a_node.astype("int32")
            self.edges.trav_time = self.edges.trav_time.astype(float)
            self.edges.freq = self.edges.freq.astype(float)
            self.edges.o_line_id = self.edges.o_line_id.fillna("").astype(str)
            self.edges.d_line_id = self.edges.d_line_id.fillna("").astype(str)
            self.edges.direction = self.edges.direction.astype("int8")

        def create_graph(self):
            """Create the SF transit graph (vertices and edges)."""
            self._create_vertices()
            self._create_edges()

        def create_line_geometry(self, method="direct", graph="w"):
            """
            Create the LineString for each edge.

            The direct method creates a straight line between all points.

            The connect project match method uses the existing line geometry within the project to create more
            accurate line strings. It creates a line string that matches the path between the shortest path
            between the project nodes closest to either end of the access and egress connectors.

            Project graphs must be built for the "connector project match" method.

            :Arguments:
               **method** (:obj:`str`): Must be either ``"direct"`` or ``"connector project match"``.
               If method is ``"direct"``, ``graph`` argument is ignored.

               **graph** (:obj:`str`): Must be a key within ``project.network.graphs``.
            """
            if method not in ["direct", "connector project match"]:
                raise ValueError("method must be either 'direct' or 'connector project match'")

            self.edges["geometry"] = None

            if method == "direct":
                self.edges["geometry"] = [
                    shapely.LineString(
                        (
                            shapely.from_wkb(self.vertices.at[row.a_node - 1, "geometry"]),
                            shapely.from_wkb(self.vertices.at[row.b_node - 1, "geometry"]),
                        )
                    ).wkb
                    for row in self.edges.itertuples()
                ]
            elif method == "connector project match":
                # Check validity of project and nodes database
                project = get_active_project(must_exist=True)
                warnings.warn(
                    'In its current implementation, the "connector project match" method may take a while for large networks.'
                )

                nodes = project.network.nodes.data[["node_id", "geometry"]].set_index("node_id")
                links = project.network.links.data[["link_id", "geometry"]].set_index("link_id")

                if len(nodes) == 0:
                    raise ValueError(
                        "No nodes found in the project database. Connector project matching requires an existing project network."
                    )

                # Create indexes for access and egress connectors
                connector_rows = (self.edges.link_type == "access_connector") | (self.edges.link_type == "egress_connector")
                other_rows = ~connector_rows

                # Create line strings for non-access and egress connectors
                self.edges.loc[other_rows, "geometry"] = [
                    shapely.LineString(
                        (
                            shapely.from_wkb(self.vertices.at[row.a_node - 1, "geometry"]),
                            shapely.from_wkb(self.vertices.at[row.b_node - 1, "geometry"]),
                        )
                    ).wkb
                    for row in self.edges[other_rows].itertuples()
                ]

                lines = self.__connector_project_match(connector_rows, project, nodes, links, graph)

                self.edges.loc[connector_rows, ("trav_time", "geometry")] = lines

        def __connector_project_match(self, connector_rows, project, nodes, links, graph_key):
            """Create line string geometry for ``connector_rows`` that matches the line strings in
            ``project.network.graphs[graph_key]``.

            :Arguments:
               **connector_rows** (:obj:`pd.Series`): Boolean series for the rows of ``self.edges`` to create line strings for.

               **project** (:obj:`Aequilibrae.project.Project`): Reference to the project to pull the graph from.

               **nodes** (:obj:`pd.DataFrame`): A Dataframe containing the project nodes. Must have columns ``geometry``, and an index of ``node_id``s.

               **links** (:obj:`pd.DataFrame`): A Dataframe containing the project links. Must have columns ``geometry``, and an index of ``link_id``s.

               **graph_key** (:obj:`str`): The key of the ``project.network.graphs`` graph to use for path finding.
            """
            # Create kdtree for fast nearest neighbour lookup on the project db nodes
            nodes["geometry"] = nodes["geometry"].apply(
                lambda geometry: shapely.ops.transform(self.transformer_g_to_p, geometry)
            )
            links["geometry"] = links["geometry"].apply(
                lambda geometry: shapely.ops.transform(self.transformer_g_to_p, geometry)
            )
            nodes_geometries = np.array(list(nodes["geometry"].apply(lambda geometry: (geometry.x, geometry.y))))
            kdtree = KDTree(nodes_geometries)

            # Prepare shortest path computation
            graph = project.network.graphs[graph_key]
            graph.set_graph("distance")
            res = PathResults()
            res.prepare(graph)

            # Loop over connect edges, query for the closest nodes in the project and create the relevant line string
            lines = []
            for row in self.edges[connector_rows].itertuples():
                # row.a_node - 1 because the node_ids are the index + 1
                start = shapely.ops.transform(
                    self.transformer_g_to_p, shapely.from_wkb(self.vertices.at[row.a_node - 1, "geometry"])
                )
                end = shapely.ops.transform(
                    self.transformer_g_to_p, shapely.from_wkb(self.vertices.at[row.b_node - 1, "geometry"])
                )

                _, ids = kdtree.query([[start.x, start.y], [end.x, end.y]], k=1)

                # If the ids for the closest nodes to the start and end are the same, then we make an edge between those 3
                # If they differ we compute the shortest path between them. If no path exists we use a straight between the start and end
                # Otherwise create a line string using the already existing link geometry.
                if ids[0] == ids[1]:
                    line = shapely.LineString((start, nodes.iloc[ids[0]].geometry, end))
                else:
                    res.compute_path(*nodes.iloc[ids].index.values)

                    if res.path_nodes is not None:
                        line = shapely.ops.linemerge(
                            (
                                shapely.LineString((start, nodes.loc[res.path_nodes[0]].geometry)),
                                *links.loc[res.path].geometry,
                                shapely.LineString((nodes.loc[res.path_nodes[-1]].geometry, end)),
                            )
                        )
                    else:
                        line = shapely.LineString((start, end))

                trav_time = line.length / self.walking_speed
                if row.link_type == "access_connector":
                    trav_time *= self.access_time_factor
                else:  # row.link_type == "egress_connector"
                    trav_time *= self.egress_time_factor

                lines.append((trav_time, shapely.ops.transform(self.transformer_p_to_g, line).wkb))
            return lines

        def create_additional_db_fields(self):
            """Create the additional required entries in the tables."""
            # This graph requires some additional tables and fields in order to store all our information.
            # Currently it mimics what we are storing in the df

            self.pt_conn.executemany(
                """
                INSERT OR IGNORE INTO link_types (link_type, link_type_id, description) VALUES (?, ?, ?)
                """,
                [
                    ("on-board", "o", "From boarding to alighting"),
                    ("boarding", "b", "From stop to boarding"),
                    ("alighting", "a", "From alighting to stop"),
                    ("dwell", "d", "From alighting to boarding"),
                    ("access_connector", "A", ""),
                    ("egress_connector", "e", ""),
                    ("inner_transfer", "t", "Transfer edge within station, from alighting to boarding"),
                    ("outer_transfer", "T", "Transfer edge outside of a station, from alighting to boarding"),
                    ("walking", "w", "Walking, from stop or walking to stop or walking"),
                ],
            )

            self.pt_conn.commit()

        def save_vertices(self, robust=True):
            """
            Write the vertices DataFrame to the public transport database.

            Within the database nodes may not exist at the exact same point in space, provide ``robust=True`` to move the nodes slightly.

            :Arguments:
               **robust** (:obj:`bool`): Whether to move stack nodes slightly before saving. Defaults to ``True``.
            """
            duplicated = self.vertices.geometry.duplicated()

            if not robust and not duplicated.empty:
                warnings.warn(
                    "Duplicated geometry was detected but robust was disabled, verticies that share the same geometry will not be saved.",
                    warnings.RuntimeWarning,
                )

            if robust and not duplicated.empty:
                df = shift_duplicate_geometry(self.vertices[["node_id", "geometry"]][duplicated])
                self.vertices.loc[df.index, "geometry"] = df.geometry

            # The below query is formatted to line the columns up The order of the tuples should be the same
            # as the order of the columns.
            #
            #     An object to iterate over namedtuples for each row in the DataFrame with the first field possibly being
            #     the index and following fields being the column values.
            # https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.itertuples.html
            self.pt_conn.executemany(
                f"""\
                INSERT INTO nodes ({",".join(SF_VERTEX_COLS)},modes)
                VALUES({",".join("?" * (len(SF_VERTEX_COLS) - 1))},GeomFromWKB(?, {self.__global_crs.to_epsg()}),"t");
                """,
                (self.vertices if robust else self.vertices[~duplicated])[SF_VERTEX_COLS].itertuples(
                    index=False, name=None
                ),
            )

            self.pt_conn.commit()

        def save_edges(self, recreate_line_geometry=False):
            """
            Save the contents of self.edges to the public transport database.

            If no geometry for the edges is present or ``recreate_line_geometry`` is ``True``, direct lines will be created.

            :Arguments:
               **recreate_line_geometry** (:obj:`bool`): Whether to recreate the line strings for the edges as direct lines. Defaults to ``False``.
            """
            # We need to generate the geometry for each edge, this may take a bit
            if "geometry" not in self.edges.columns or recreate_line_geometry:
                self.create_line_geometry()

            # In order to save the line strings from connector project matching we need to disable some smart node creation.
            # It will be restored to its previous value once we are done here.
            val = self.pt_conn.execute(
                "SELECT enabled FROM trigger_settings WHERE name = 'new_link_a_or_b_node'"
            ).fetchone()[0]
            self.pt_conn.execute("UPDATE trigger_settings SET enabled = ? WHERE name = 'new_link_a_or_b_node'", (False,))
            self.pt_conn.executemany(
                f"""\
                INSERT INTO links ({",".join(SF_EDGE_COLS)},geometry,modes)
                VALUES({",".join("?" * len(SF_EDGE_COLS))},GeomFromWKB(?, {self.__global_crs.to_epsg()}),"t");
                """,
                self.edges[SF_EDGE_COLS + ["geometry"]].itertuples(index=False, name=None),
            )

            self.pt_conn.execute("UPDATE trigger_settings SET enabled = ? WHERE name = 'new_link_a_or_b_node'", (val,))
            self.pt_conn.commit()

        def save_config(self):
            sql = "INSERT OR REPLACE INTO transit_graph_configs (period_id,config) VALUES (?,?)"
            self.project_conn.execute(sql, [self.period_id, json.dumps(self.config)])
            self.project_conn.commit()

        def save(self, robust=True):
            """Save the current graph to the public transport database.

            :Arguments:
               **recreate_line_geometry** (:obj:`bool`): Whether to recreate the line strings for the edges as direct lines. Defaults to ``False``.
            """
            self.create_additional_db_fields()
            self.save_vertices(robust=robust)
            self.save_edges()
            self.save_config()

        def to_transit_graph(self) -> TransitGraph:
            """Create an AequilibraE ``TransitGraph`` object from an SF graph builder."""

            # TODO: Better required link type detections
            # link_type_diff = set(self.edges.link_type.unique()) ^ {'access_connector', 'alighting', 'boarding', 'dwell', 'egress_connector', 'inner_transfer', 'on-board'}
            # if link_type_diff:
            #     raise ValueError(f"Not all required links have been created. Link types {link_type_diff} are missing.")

            g = TransitGraph(config=self.config, od_node_mapping=self.od_node_mapping)
            g.network = self.edges.copy(deep=True)
            g.cost = g.network.trav_time.values
            g.free_flow_time = g.network.trav_time.values

            g.network["id"] = g.network.link_id
            g.prepare_graph(
                self.vertices[
                    (
                        (self.vertices.node_type == "origin")
                        if self.blocking_centroid_flows
                        else (self.vertices.node_type == "od")
                    )
                ].node_id.values
            )
            g.set_graph("trav_time")
            g.set_blocked_centroid_flows(True)
            g.graph.__compressed_id__ = g.graph.__compressed_id__.astype("int32")

            return g

        @classmethod
        def from_db(cls, public_transport_conn, period_id: int, **kwargs):
            """
            Create a SF graph instance from an existing database save.

            Assumes the database was constructed with the provided save methods.
            No checks are performed to see if the provided arguments are compatible with the saved graph.

            All arguments are forwarded to the constructor.

            :Arguments:
               **public_transport_conn** (:obj:`sqlite3.Connection`): Connection to the 'public_transport.sqlite' database.
            """
            project_conn = database_connection("project_database")
            config = json.loads(
                project_conn.execute(
                    "SELECT config FROM transit_graph_configs WHERE period_id = ? LIMIT 1;", [period_id]
                ).fetchone()[0]
            )
            config.update(kwargs)

            graph = cls(public_transport_conn, **config)

            # FIXME Load specific period_id graph from dynamic table
            graph.vertices = pd.read_sql_query(
                sql=f"SELECT {','.join(SF_VERTEX_COLS)} FROM nodes;",
                con=public_transport_conn,
            )

            graph.edges = pd.read_sql_query(
                sql=f"SELECT {','.join(SF_EDGE_COLS)} FROM links;",
                con=public_transport_conn,
            )

            return graph

        def convert_demand_matrix_from_zone_to_node_ids(
            self, demand_matrix, o_zone_col="origin_zone_id", d_zone_col="destination_zone", demand_col="demand"
        ):
            """Convert a sparse demand matrix from ``zone_id``\'s to the corresponding ``node_id``\'s."""
            if self.blocking_centroid_flows:
                od_matrix = pd.merge(
                    demand_matrix,
                    self.od_node_mapping[["o_node_id", "taz_id"]],
                    left_on=o_zone_col,
                    right_on="taz_id",
                )[["o_node_id", d_zone_col, "demand"]]
                od_matrix = pd.merge(
                    od_matrix,
                    self.od_node_mapping[["d_node_id", "taz_id"]],
                    left_on=d_zone_col,
                    right_on="taz_id",
                )[["o_node_id", "d_node_id", "demand"]]
            else:
                od_matrix = pd.merge(
                    demand_matrix,
                    self.od_node_mapping[["node_id", "taz_id"]],
                    left_on=o_zone_col,
                    right_on="taz_id",
                )[["node_id", d_zone_col, "demand"]]
                od_matrix.rename(columns={"node_id": "o_node_id"}, inplace=True)
                od_matrix = pd.merge(
                    od_matrix,
                    self.od_node_mapping[["node_id", "taz_id"]],
                    left_on=d_zone_col,
                    right_on="taz_id",
                )[["o_node_id", "node_id", "demand"]]
                od_matrix.rename(columns={"node_id": "d_node_id"}, inplace=True)
            return od_matrix

        @property
        def config(self):
            return {k: self.__dict__[k] for k in self.__config_attrs}