Source code for modelly.space.geo.roads

"""Road networks: a NetworkX graph with node coordinates as a ``Network`` space, and routing.

Nodes carry their coordinates as ``x`` and ``y`` attributes (the OSMnx convention) and
edges a ``length``; edges without one get the straight-line length between their ends.
The graph is reprojected once into the network's CRS. Agents can stand on its nodes as
``CellAgent``s of ``space``, and route with NetworkX (one pair) or scipy (many sources).
"""

from __future__ import annotations

from random import Random
from typing import TYPE_CHECKING, Any

import numpy as np
import pyproj
import shapely

from modelly.space.geo._deps import require
from modelly.space.network import Network

if TYPE_CHECKING:
    from scipy.sparse import csr_matrix

    from modelly.core.agent import Agent
    from modelly.space.geo.space import GeoSpace

LENGTH = "length"
TRAVEL_TIME = "travel_time"
SPEED = "speed"


[docs] class RoadNetwork: """A road graph in one projected CRS, its ``Network`` space and shortest paths. Attributes: graph: the NetworkX graph (any of Graph, DiGraph, MultiGraph, MultiDiGraph). crs: the ``pyproj.CRS`` of the node coordinates, or ``None``. space: a ``Network`` space with one cell per node, at the node's coordinates. weight: the default edge attribute for routing. """ def __init__( self, graph: Any, crs: Any = None, random: Random | None = None, weight: str = LENGTH, ) -> None: """Wrap ``graph``; its own CRS is ``graph.graph["crs"]``, as OSMnx sets it. Args: graph: a NetworkX graph whose nodes have ``x`` and ``y`` attributes. crs: the CRS to route in; defaults to the graph's, or its UTM zone if geographic. random: a seeded ``random.Random`` for the ``Network`` space. weight: the default edge attribute shortest paths minimise. """ source = graph.graph.get("crs") self.crs = _target_crs(graph, source, crs) if source is not None and self.crs is not None: graph = _reproject_graph( graph, pyproj.CRS.from_user_input(source), self.crs ) _fill_lengths(graph) self.graph = graph self.weight = weight self.nodes = list(graph.nodes) self._row = {node: i for i, node in enumerate(self.nodes)} layout = {n: (d["x"], d["y"]) for n, d in graph.nodes(data=True)} self.space = Network(graph, random=random, layout=layout) self._csr_cache: dict[str, csr_matrix] = {} def __getstate__(self) -> dict: """Drop the routing matrices; they are rebuilt on first use after a load.""" state = self.__dict__.copy() state["_csr_cache"] = {} return state
[docs] def node_of(self, point: Any) -> Any: """``point`` itself if it is a node, else the node nearest to it (``Point`` or ``(x, y)``).""" try: if point in self._row: return point except TypeError: pass if isinstance(point, shapely.Point): point = (point.x, point.y) return self.space.find_nearest_cell(np.asarray(point, dtype=float)).coordinate
[docs] def nearest_nodes(self, xy: np.ndarray) -> np.ndarray: """Row (position in ``nodes``) of the node nearest to every point of ``xy``.""" if self.space._kdtree_dirty: self.space._rebuild_kdtree() _, rows = self.space._kdtree.query(np.atleast_2d(xy)) cells = self.space._kdtree_cells return np.fromiter( (self._row[cells[r].coordinate] for r in rows), dtype=np.intp )
[docs] def shortest_path( self, origin: Any, destination: Any, weight: str | None = None ) -> list: """The nodes of a shortest path; ``origin`` and ``destination`` are nodes or points.""" import networkx as nx # noqa: PLC0415 return nx.shortest_path( self.graph, self.node_of(origin), self.node_of(destination), weight=weight or self.weight, )
[docs] def path_length( self, origin: Any, destination: Any, weight: str | None = None ) -> float: """The total ``weight`` of a shortest path between two nodes or points.""" import networkx as nx # noqa: PLC0415 return float( nx.shortest_path_length( self.graph, self.node_of(origin), self.node_of(destination), weight=weight or self.weight, ) )
[docs] def travel_time(self, origin: Any, destination: Any) -> float: """The shortest travel time between two nodes or points (see ``add_travel_times``).""" return self.path_length(origin, destination, weight=TRAVEL_TIME)
[docs] def add_travel_times(self, speed: float) -> None: """Set ``travel_time = length / speed`` on every edge. Args: speed: in CRS units per unit of model time, for edges without a ``speed`` attribute of their own. """ for _, _, data in self.graph.edges(data=True): data[TRAVEL_TIME] = data[LENGTH] / data.get(SPEED, speed) self.invalidate(TRAVEL_TIME)
[docs] def distances_from( self, origin: Any, cutoff: float | None = None, weight: str | None = None ) -> dict: """Shortest ``weight`` from ``origin`` to every node reachable within ``cutoff``.""" import networkx as nx # noqa: PLC0415 return nx.single_source_dijkstra_path_length( self.graph, self.node_of(origin), cutoff=cutoff, weight=weight or self.weight, )
[docs] def distance_matrix(self, origins: Any, weight: str | None = None) -> np.ndarray: """Shortest ``weight`` from every origin (nodes or points) to every node, with scipy. Returns: an array of shape ``(len(origins), len(nodes))``; unreachable nodes are ``inf``. """ from scipy.sparse.csgraph import dijkstra # noqa: PLC0415 - scipy is heavy rows = [self._row[self.node_of(origin)] for origin in origins] return dijkstra(self.routing_matrix(weight), directed=True, indices=rows)
[docs] def routing_matrix(self, weight: str | None = None) -> csr_matrix: """The graph as a CSR matrix of ``weight``, rows in ``nodes`` order. Parallel edges keep the smallest weight; an undirected edge counts both ways. """ weight = weight or self.weight if weight not in self._csr_cache: self._csr_cache[weight] = _routing_matrix(self, weight) return self._csr_cache[weight]
[docs] def invalidate(self, weight: str | None = None) -> None: """Forget the routing matrix of ``weight`` (all when ``None``) after edges changed.""" if weight is None: self._csr_cache.clear() else: self._csr_cache.pop(weight, None)
[docs] def path_geometry(self, path: list) -> shapely.LineString: """The polyline through the coordinates of the nodes of ``path``.""" nodes = self.graph.nodes return shapely.LineString([(nodes[n]["x"], nodes[n]["y"]) for n in path])
[docs] def agents_within( self, space: GeoSpace, origin: Any, distance: float, weight: str | None = None ) -> list[Agent]: """The agents of ``space`` whose nearest node is within network ``distance`` of ``origin``.""" reached = self.distances_from(origin, cutoff=distance, weight=weight) mask = np.zeros(len(self.nodes), dtype=bool) mask[[self._row[node] for node in reached]] = True near = mask[self.nearest_nodes(space.agent_positions)] return [space.active_agents[i] for i in np.flatnonzero(near)]
[docs] def download_osm( place: str, network_type: str = "drive", crs: Any = None, random: Random | None = None, ) -> RoadNetwork: """Download the road network of ``place`` from OpenStreetMap (needs the ``osm`` extra). Edges get OSMnx's ``speed_kph`` and ``travel_time`` (seconds) attributes. This makes a network request: cache the result (``osmnx.save_graphml``) rather than calling it per run. """ ox = require("osmnx", "osm") graph = ox.graph_from_place(place, network_type=network_type) graph = ox.add_edge_travel_times(ox.add_edge_speeds(graph)) return RoadNetwork(graph, crs=crs, random=random)
def _target_crs(graph: Any, source: Any, crs: Any) -> pyproj.CRS | None: """The CRS asked for, else the graph's, replaced by its UTM zone when geographic.""" if crs is not None: return pyproj.CRS.from_user_input(crs) if source is None: return None source = pyproj.CRS.from_user_input(source) if not source.is_geographic: return source import geopandas as gpd # noqa: PLC0415 xy = np.array([(d["x"], d["y"]) for _, d in graph.nodes(data=True)]) return gpd.GeoSeries(shapely.points(xy), crs=source).estimate_utm_crs() def _reproject_graph(graph: Any, source: pyproj.CRS, target: pyproj.CRS) -> Any: """A copy of ``graph`` with node coordinates and edge geometries in ``target``.""" if source == target: return graph transformer = pyproj.Transformer.from_crs(source, target, always_xy=True) graph = graph.copy() for _, data in graph.nodes(data=True): data["x"], data["y"] = transformer.transform(data["x"], data["y"]) for _, _, data in graph.edges(data=True): if "geometry" in data: data["geometry"] = shapely.transform( data["geometry"], lambda xy: np.column_stack(transformer.transform(*xy.T)), ) graph.graph["crs"] = target.to_wkt() return graph def _fill_lengths(graph: Any) -> None: """Give every edge without a ``length`` the straight-line length between its ends.""" nodes = graph.nodes for u, v, data in graph.edges(data=True): if LENGTH not in data: dx = nodes[u]["x"] - nodes[v]["x"] dy = nodes[u]["y"] - nodes[v]["y"] data[LENGTH] = float(np.hypot(dx, dy)) def _routing_matrix(roads: RoadNetwork, weight: str) -> csr_matrix: """The smallest ``weight`` per directed node pair, as a CSR matrix.""" from scipy.sparse import csr_matrix # noqa: PLC0415 - scipy is heavy best: dict[tuple[int, int], float] = {} row = roads._row both_ways = not roads.graph.is_directed() for u, v, data in roads.graph.edges(data=True): pairs = ( ((row[u], row[v]), (row[v], row[u])) if both_ways else ((row[u], row[v]),) ) for pair in pairs: best[pair] = min(best.get(pair, np.inf), float(data[weight])) n = len(roads.nodes) if not best: return csr_matrix((n, n)) pairs = np.array(list(best), dtype=np.intp) weights = np.fromiter(best.values(), dtype=float, count=len(best)) return csr_matrix((weights, (pairs[:, 0], pairs[:, 1])), shape=(n, n))