import concurrent.futures
from typing import Any
import geopandas as gpd
import pandas as pd
from shapely import MultiPolygon, Polygon
from iduedu import config
from iduedu.graph.editors import apply_urban_graph_changes, project_objects2urban_graph
from iduedu.graph.transformers import keep_largest_connected_component
from iduedu.graph.urban_graph import UrbanGraph
from iduedu.graph_builders.drive_walk_builders import get_walk_graph
from iduedu.graph_builders.public_transport_builders import get_public_transport_graph
from iduedu.overpass.downloaders import get_4326_boundary
logger = config.logger
PLATFORM_NODE_TYPES = {"platform", "subway_platform", "subway_entry_exit", "subway_entry", "subway_exit"}
DEFAULT_WALK_SPEED_M_PER_MIN = 5 * 1000 / 60
[docs]
def join_pt_walk_graph(
public_transport_g: UrbanGraph,
walk_g: UrbanGraph,
max_dist: float = 20,
keep_largest_subgraph: bool = True,
*,
add_link_edge: bool = True,
walk_speed_m_per_min: float | None = None,
) -> UrbanGraph:
"""
Join public-transport and pedestrian ``UrbanGraph`` objects into an intermodal graph.
Public-transport platform-like nodes are projected to the walk graph via
:func:`project_objects2urban_graph`. Platforms farther than ``max_dist``
from the walking network are left as regular PT nodes.
See also:
https://iduclub.github.io/IduEdu/examples/get_any_graph.html
https://iduclub.github.io/IduEdu/examples/objects_and_nearest_nodes.html
"""
if public_transport_g.crs != walk_g.crs:
raise ValueError(f"CRS mismatch: public_transport_g.crs={public_transport_g.crs}, walk_g.crs={walk_g.crs}")
if public_transport_g.nodes_gdf.empty:
logger.warning("Public transport graph is empty. Returning walk graph unchanged.")
return walk_g
if walk_g.nodes_gdf.empty:
logger.warning("Walk graph is empty. Returning public transport graph unchanged.")
return public_transport_g
logger.info("Composing intermodal graph...")
if walk_speed_m_per_min is not None:
walk_speed = walk_speed_m_per_min
else:
walk_speed = DEFAULT_WALK_SPEED_M_PER_MIN
walk_directed = walk_g.to_directed(edge_direction_column="oneway", default_direction_value=False)
pt_nodes = public_transport_g.nodes_gdf.copy()
platform_mask = (
pt_nodes["type"].isin(PLATFORM_NODE_TYPES)
if "type" in pt_nodes.columns
else pd.Series(False, index=pt_nodes.index)
)
pt_platforms = pt_nodes.loc[platform_mask].copy()
if pt_platforms.empty:
logger.warning("Public transport graph has no platform-like nodes. Joining graphs without projection.")
projected_walk = walk_directed
object2node_map = pd.Series(dtype=int)
else:
changes, object2node_map = project_objects2urban_graph(
walk_directed,
pt_platforms,
walk_speed,
max_dist=max_dist,
add_link_edge=add_link_edge,
)
projected_walk = apply_urban_graph_changes(walk_directed, changes)
walk_edges = projected_walk.edges_gdf.copy()
if "type" not in walk_edges.columns:
walk_edges["type"] = "walk"
else:
walk_edges["type"] = walk_edges["type"].fillna("walk")
walk_edges["oneway"] = walk_edges["oneway"].map(lambda value: False if pd.isna(value) else bool(value))
projected_walk = UrbanGraph(
nodes_gdf=projected_walk.nodes_gdf.copy(),
edges_gdf=walk_edges,
is_multigraph=projected_walk.is_multigraph,
is_directed=projected_walk.is_directed,
edge_direction_column=projected_walk.edge_direction_column,
adjacency_weight=projected_walk.adjacency_weight,
crs=projected_walk.crs,
graph_type=projected_walk.type,
)
if not object2node_map.empty:
walk_nodes = projected_walk.nodes_gdf.copy()
mapped_platforms = object2node_map.dropna().astype(int).rename("target_node").to_frame()
node_attrs = pt_nodes.drop(columns=pt_nodes.geometry.name).join(mapped_platforms, how="inner")
attr_columns = [column for column in node_attrs.columns if column != "target_node"]
if attr_columns:
for column in attr_columns:
is_sequence = node_attrs[column].map(lambda value: isinstance(value, (list, tuple, set)))
node_attrs.loc[is_sequence, column] = node_attrs.loc[is_sequence, column].map(list)
def collapse_values(values):
"""Collapse equal values to a scalar and preserve multiple values as a list."""
unique_values = list(dict.fromkeys(values.dropna()))
if len(unique_values) == 1:
return unique_values[0]
return unique_values
attrs = pd.DataFrame(index=pd.Index(node_attrs["target_node"].drop_duplicates(), name="target_node"))
for column in attr_columns:
attr_long = node_attrs[["target_node", column]].explode(column)
attr_long = attr_long.dropna(subset=[column])
if attr_long.empty:
continue
attrs[column] = attr_long.groupby("target_node", sort=False)[column].agg(collapse_values)
for column in attrs.columns:
if column not in walk_nodes.columns:
walk_nodes[column] = pd.NA
walk_nodes.loc[attrs.index, column] = attrs[column]
projected_walk = UrbanGraph(
nodes_gdf=walk_nodes,
edges_gdf=projected_walk.edges_gdf.copy(),
is_multigraph=projected_walk.is_multigraph,
is_directed=projected_walk.is_directed,
edge_direction_column=projected_walk.edge_direction_column,
adjacency_weight=projected_walk.adjacency_weight,
crs=projected_walk.crs,
graph_type=projected_walk.type,
)
projected_mapping = object2node_map.dropna().astype(int).to_dict()
pt_nodes_remapped = pt_nodes.loc[~pt_nodes.index.isin(projected_mapping.keys())].copy()
relabel_mapping = dict(projected_mapping)
start_node_id = 0 if projected_walk.nodes_gdf.empty else int(pd.Index(projected_walk.nodes_gdf.index).max()) + 1
new_index = pd.RangeIndex(start_node_id, start_node_id + len(pt_nodes_remapped))
relabel_mapping.update(dict(zip(pt_nodes_remapped.index, new_index)))
pt_nodes_remapped.index = new_index
pt_edges_remapped = public_transport_g.edges_gdf.copy()
if not pt_edges_remapped.empty:
pt_edges_remapped["u"] = pt_edges_remapped["u"].map(relabel_mapping)
pt_edges_remapped["v"] = pt_edges_remapped["v"].map(relabel_mapping)
pt_edges_remapped = pt_edges_remapped.dropna(subset=["u", "v"]).copy()
if not pt_edges_remapped.empty:
pt_edges_remapped[["u", "v"]] = pt_edges_remapped[["u", "v"]].astype(int)
if public_transport_g.edge_direction_column is not None:
pt_edges_remapped[public_transport_g.edge_direction_column] = pt_edges_remapped[
public_transport_g.edge_direction_column
].astype(bool)
if public_transport_g.is_multigraph:
pt_edges_remapped["k"] = pt_edges_remapped.groupby(["u", "v"], sort=False).cumcount()
nodes = gpd.GeoDataFrame(
pd.concat([projected_walk.nodes_gdf, pt_nodes_remapped], axis=0, sort=False),
geometry=projected_walk.nodes_gdf.geometry.name,
crs=projected_walk.crs,
)
edges = gpd.GeoDataFrame(
pd.concat([projected_walk.edges_gdf, pt_edges_remapped], axis=0, ignore_index=True, sort=False),
geometry=projected_walk.edges_gdf.geometry.name,
crs=projected_walk.crs,
)
if not edges.empty:
edges["oneway"] = edges["oneway"].astype(bool)
edges["k"] = edges.groupby(["u", "v"], sort=False).cumcount()
intermodal = UrbanGraph(
nodes_gdf=nodes,
edges_gdf=edges,
is_multigraph=True,
is_directed=True,
edge_direction_column="oneway",
adjacency_weight=projected_walk.adjacency_weight,
crs=projected_walk.crs,
graph_type="intermodal",
)
if keep_largest_subgraph:
intermodal = keep_largest_connected_component(intermodal)
logger.debug("Done composing intermodal UrbanGraph.")
return intermodal
[docs]
def get_intermodal_graph(
*,
osm_id: int | None = None,
territory: Polygon | MultiPolygon | gpd.GeoDataFrame | None = None,
clip_by_territory: bool = False,
osm_edge_tags: list[str] | None = None,
max_dist: float = 30,
keep_largest_subgraph: bool = True,
add_link_edge: bool = True,
walk_kwargs: dict[str, Any] | None = None,
pt_kwargs: dict[str, Any] | None = None,
) -> UrbanGraph:
"""
Build an intermodal (PT+walking) ``UrbanGraph`` for a territory.
The function resolves a boundary polygon (by ``osm_id`` or ``territory``),
runs pedestrian graph construction (:func:`get_walk_graph`) and public
transport graph construction (:func:`get_public_transport_graph`) in
parallel, then connects public-transport platforms to nearby walk edges via
:func:`join_pt_walk_graph` using the ``max_dist`` snapping radius. Edge
lengths (meters) and travel times (minutes) come from the underlying
builders and from the walk-edge splits created during projection.
Parameters:
osm_id (int | None): OSM relation/area id for the territory; provide this or ``territory``.
territory (Polygon | MultiPolygon | gpd.GeoDataFrame | None): Boundary geometry in EPSG:4326.
clip_by_territory (bool): If True, both PT and Walk graphs are clipped to the boundary.
osm_edge_tags (list[str] | None): Subset of OSM tags to retain (forwarded to both builders).
max_dist (float): Max distance in meters to connect PT platforms to walk edges.
keep_largest_subgraph (bool): If True, keep only the largest strongly connected component after joining.
add_link_edge (bool): If True, add platform-to-walk connector edges during projection.
walk_kwargs (dict[str, Any] | None): Extra keyword args for :func:`get_walk_graph` (e.g., ``walk_speed``,
``simplify``, ``osm_edge_tags``, ``keep_largest_subgraph``). Walk graph keep_largest_subgraph defaults to
False unless explicitly set here.
pt_kwargs (dict[str, Any] | None): Extra keyword args for :func:`get_public_transport_graph`
(e.g., ``transport_types``, ``osm_edge_tags``, ``transport_registry``).
Returns:
Intermodal ``UrbanGraph`` combining public transport and pedestrian
edges. The graph CRS equals the builders' local projected CRS.
Notes:
- ``clip_by_territory`` and ``osm_edge_tags`` are propagated to both builders unless
overridden in ``walk_kwargs``/``pt_kwargs``.
- If the PT graph is empty for the area, the function returns the walking graph alone (with a warning).
- Joining requires both sub-graphs to share the same CRS; builders derive a local projected CRS
from the boundary's extent, so lengths/times are in meters/minutes.
See also:
https://iduclub.github.io/IduEdu/examples/get_any_graph.html
"""
boundary = get_4326_boundary(osm_id=osm_id, territory=territory)
walk_kwargs = dict(walk_kwargs or {})
pt_kwargs = dict(pt_kwargs or {})
for kwargs in (walk_kwargs, pt_kwargs):
if osm_edge_tags is not None:
kwargs.setdefault("osm_edge_tags", osm_edge_tags)
kwargs.setdefault("clip_by_territory", clip_by_territory)
walk_kwargs.setdefault("keep_largest_subgraph", False)
with concurrent.futures.ThreadPoolExecutor() as executor:
walk_future = executor.submit(get_walk_graph, territory=boundary, **walk_kwargs)
logger.debug("Started downloading and parsing walk graph...")
pt_future = executor.submit(get_public_transport_graph, territory=boundary, **pt_kwargs)
logger.debug("Started downloading and parsing public transport graph...")
pt_g = pt_future.result()
logger.debug("Public transport graph done!")
walk_g = walk_future.result()
logger.debug("Walk graph done!")
if pt_g.nodes_gdf.empty:
logger.warning("Public transport graph is empty! Returning only walk graph.")
return walk_g
walk_speed = walk_kwargs.get("walk_speed")
intermodal = join_pt_walk_graph(
pt_g,
walk_g,
max_dist=max_dist,
keep_largest_subgraph=keep_largest_subgraph,
add_link_edge=add_link_edge,
walk_speed_m_per_min=walk_speed,
)
return intermodal