From c69efcbc4d2fd6d65b794a765b748db243ef7bbe Mon Sep 17 00:00:00 2001 From: Gareth Simons Date: Thu, 4 Aug 2022 17:44:15 +0100 Subject: [PATCH] moves io methods to new io module; adds open roads io; --- cityseer/metrics/__init__.py | 2 +- cityseer/metrics/networks.py | 2 +- cityseer/metrics/observe.py | 117 ++++++++ cityseer/tools/__init__.py | 2 +- cityseer/tools/graphs.py | 244 ++++++----------- cityseer/tools/io.py | 438 ++++++++++++++++++++++++++++++ cityseer/tools/mock.py | 2 +- cityseer/tools/osm.py | 225 --------------- demos/general_util/plot_funcs.py | 176 ++++++------ docs/plots/plots.py | 16 +- docs/src/layouts/PageLayout.astro | 3 +- docs/src/pages/examples.md | 10 +- docs/src/pages/guide.md | 24 +- pdm.lock | 26 +- pyproject.toml | 5 +- tests/tools/test_graphs.py | 5 - 16 files changed, 778 insertions(+), 519 deletions(-) create mode 100644 cityseer/metrics/observe.py create mode 100644 cityseer/tools/io.py delete mode 100644 cityseer/tools/osm.py diff --git a/cityseer/metrics/__init__.py b/cityseer/metrics/__init__.py index 1cf6a18b..5612aebe 100644 --- a/cityseer/metrics/__init__.py +++ b/cityseer/metrics/__init__.py @@ -1 +1 @@ -from cityseer.metrics import layers, networks # type: ignore +from cityseer.metrics import layers, networks, observe # type: ignore diff --git a/cityseer/metrics/networks.py b/cityseer/metrics/networks.py index bd7753e9..e113facc 100644 --- a/cityseer/metrics/networks.py +++ b/cityseer/metrics/networks.py @@ -1,5 +1,5 @@ r""" -Cityseer network module for creating networks and calculating network centralities. +Cityseer networks module for calculating network centralities using optimised JIT compiled functions. There are two network centrality methods available depending on whether you're using a node-based or segment-based approach: diff --git a/cityseer/metrics/observe.py b/cityseer/metrics/observe.py new file mode 100644 index 00000000..09191e09 --- /dev/null +++ b/cityseer/metrics/observe.py @@ -0,0 +1,117 @@ +""" +Observe module for computing observations derived from `networkX` graphs. + +These methods are generally sufficiently simple that further computational optimisation is not required. Network +centrality methods (which do require further computational optimisation due to their complexity) are handled separately +in the [`networks`](/metrics/networks/) module. + +""" +from typing import Any + +import networkx as nx +from tqdm import tqdm + +from cityseer.tools.graphs import EdgeData, NodeKey + + +def route_continuity(nx_multigraph: nx.MultiGraph, method: str) -> nx.MultiGraph: + """ + Compute the route continuity for a given graph. + """ + nx_multi_copy: nx.MultiGraph = nx_multigraph.copy() + + def _clean_vals(vals: list[str]) -> set[str]: + clean_vals: list[str] = [] + for val in vals: + # highways category has residential, service, etc. + if val not in [None, "residential", "service", "footway"]: + clean_vals.append(val) + return set(clean_vals) + + def _intersect_vals(vals_a: list[str], vals_b: list[str]) -> bool: + """Find set overlaps between values for set A and set B.""" + clean_vals_a = _clean_vals(vals_a) + clean_vals_b = _clean_vals(vals_b) + itx = clean_vals_a.intersection(clean_vals_b) + return len(itx) > 0 + + def _recurse_edges( + _nx_multigraph: nx.MultiGraph, + _target_key: str, + _target_vals: list[str], + _a_nd_key: NodeKey, + _b_nd_key: NodeKey, + _edge_idx: int, + agg_edge_lengths: list[float], + visited_edges: list[str], + ): + edge_nodes = tuple(sorted([str(_a_nd_key), str(_b_nd_key)])) + edge_key = f"{edge_nodes[0]}_{edge_nodes[1]}_{_edge_idx}" + if edge_key in visited_edges: + return + visited_edges.append(edge_key) + nested_edge_data: EdgeData = _nx_multigraph[_a_nd_key][_b_nd_key][_edge_idx] + if _target_key not in nested_edge_data: + return + nested_target_vals: list[str] = nested_edge_data[_target_key] + if not _intersect_vals(_target_vals, nested_target_vals): + return + agg_edge_lengths.append(nested_edge_data["geom"].length) + # find all neighbouring edge pairs + a_nb_pairs: list[tuple[NodeKey, NodeKey]] = [ + (_a_nd_key, ann) for ann in nx.neighbors(_nx_multigraph, _a_nd_key) if ann != _b_nd_key # type: ignore + ] + b_nb_pairs: list[tuple[NodeKey, NodeKey]] = [ + (_b_nd_key, bnn) for bnn in nx.neighbors(_nx_multigraph, _b_nd_key) if bnn != _a_nd_key # type: ignore + ] + for nested_a_nd_key, nested_b_nd_key in a_nb_pairs + b_nb_pairs: + nested_edge_idx: int + for nested_edge_idx in _nx_multigraph[nested_a_nd_key][nested_b_nd_key].keys(): + _recurse_edges( + _nx_multigraph, + _target_key, + _target_vals, + nested_a_nd_key, + nested_b_nd_key, + nested_edge_idx, + agg_edge_lengths, + visited_edges, + ) + + if method in ["names", "refs", "highways"]: + target_key: str = method + else: + raise ValueError(f"Method of {method} is not recognised.") + + # iter edges + edge_data: dict[str, Any] + a_nd_key: NodeKey + b_nd_key: NodeKey + edge_idx: int + for a_nd_key, b_nd_key, edge_idx, edge_data in tqdm(nx_multi_copy.edges(keys=True, data=True)): # type: ignore + if target_key not in edge_data: + nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg"] = None # type: ignore + target_vals = edge_data[target_key] + agg_edge_lengths: list[float] = [] + visited_edges: list[str] = [] + _recurse_edges( + nx_multi_copy, target_key, target_vals, a_nd_key, b_nd_key, edge_idx, agg_edge_lengths, visited_edges + ) + # length sum + agg_len = sum(agg_edge_lengths) + if f"{target_key}_agg" in nx_multi_copy[a_nd_key][b_nd_key][edge_idx]: + current_agg_len: float = nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg_length"] + if agg_len > current_agg_len: + nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg_length"] = agg_len + else: + nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg_length"] = agg_len + # counts + agg_count = len(agg_edge_lengths) + if f"{target_key}_agg_count" in nx_multi_copy[a_nd_key][b_nd_key][edge_idx]: + current_agg_count: float = nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg_count"] + if agg_count > current_agg_count: + nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg_count"] = agg_count + else: + nx_multi_copy[a_nd_key][b_nd_key][edge_idx][f"{target_key}_agg_count"] = agg_count + + return nx_multi_copy diff --git a/cityseer/tools/__init__.py b/cityseer/tools/__init__.py index e8698ad7..3bccc27d 100644 --- a/cityseer/tools/__init__.py +++ b/cityseer/tools/__init__.py @@ -1 +1 @@ -from cityseer.tools import graphs, mock, osm, plot # type: ignore +from cityseer.tools import graphs, io, mock, plot # type: ignore diff --git a/cityseer/tools/graphs.py b/cityseer/tools/graphs.py index 80adf4a5..55960193 100644 --- a/cityseer/tools/graphs.py +++ b/cityseer/tools/graphs.py @@ -353,27 +353,53 @@ def _snap_linestring_idx( return list_linestring_coords -def _snap_linestring_startpoint( +def snap_linestring_startpoint( linestring_coords: AnyCoordsType, x_y: CoordsType, ) -> ListCoordsType: """ Snaps a LineString's start-point coordinate to a specified x_y coordinate. + + Parameters + ---------- + linestring_coords: tuple | list | np.ndarray + A list, tuple, or numpy array of x, y coordinate tuples. + x_y: tuple[float, float] + A tuple of floats representing the target x, y coordinates against which to align the linestring start point. + + Returns + ------- + linestring_coords + A list of linestring coords aligned to the specified starting point. + """ return _snap_linestring_idx(linestring_coords, 0, x_y) -def _snap_linestring_endpoint( +def snap_linestring_endpoint( linestring_coords: AnyCoordsType, x_y: CoordsType, ) -> ListCoordsType: """ Snaps a LineString's end-point coordinate to a specified x_y coordinate. + + Parameters + ---------- + linestring_coords: tuple | list | np.ndarray + A list, tuple, or numpy array of x, y coordinate tuples. + x_y: tuple[float, float] + A tuple of floats representing the target x, y coordinates against which to align the linestring end point. + + Returns + ------- + linestring_coords + A list of linestring coords aligned to the specified ending point. + """ return _snap_linestring_idx(linestring_coords, -1, x_y) -def _align_linestring_coords( +def align_linestring_coords( linestring_coords: AnyCoordsType, x_y: CoordsType, reverse: bool = False, @@ -382,8 +408,22 @@ def _align_linestring_coords( """ Align a LineString's coordinate order to either start or end at a specified x_y coordinate within a given tolerance. - If reverse=False the coordinate order will be aligned to start from the given x_y coordinate. - If reverse=True the coordinate order will be aligned to end at the given x_y coordinate. + Parameters + ---------- + linestring_coords: tuple | list | np.ndarray + A list, tuple, or numpy array of x, y coordinate tuples. + x_y: tuple[float, float] + A tuple of floats representing the target x, y coordinates against which to align the linestring coords. + reverse: bool + If reverse=False the coordinate order will be aligned to start from the given x_y coordinate. If reverse=True + the coordinate order will be aligned to end at the given x_y coordinate. False by default. + tolerance: float + Distance tolerance in metres for matching the x_y coordinate to the linestring_coords. By default 0.5. + + Returns + ------- + linestring_coords + A list of linestring coords aligned to the specified endpoint. """ # check types @@ -443,22 +483,22 @@ def _weld_linestring_coords( # in this case it is necessary to know which is the inner side of the weld and which is the outer endpoint if force_xy: if not np.allclose(linestring_coords_a[-1][:2], force_xy, atol=tolerance, rtol=0): - coords_a = _align_linestring_coords(linestring_coords_a, force_xy, reverse=True) + coords_a = align_linestring_coords(linestring_coords_a, force_xy, reverse=True) else: coords_a = linestring_coords_a if not np.allclose(linestring_coords_b[0][:2], force_xy, atol=tolerance, rtol=0): - coords_b = _align_linestring_coords(linestring_coords_b, force_xy, reverse=False) + coords_b = align_linestring_coords(linestring_coords_b, force_xy, reverse=False) else: coords_b = linestring_coords_b # case A: the linestring_b has to be flipped to start from x, y elif np.allclose(linestring_coords_a[-1][:2], linestring_coords_b[-1][:2], atol=tolerance, rtol=0): anchor_xy = linestring_coords_a[-1][:2] coords_a = linestring_coords_a - coords_b = _align_linestring_coords(linestring_coords_b, anchor_xy) + coords_b = align_linestring_coords(linestring_coords_b, anchor_xy) # case B: linestring_a has to be flipped to end at x, y elif np.allclose(linestring_coords_a[0][:2], linestring_coords_b[0][:2], atol=tolerance, rtol=0): anchor_xy = linestring_coords_a[0][:2] - coords_a = _align_linestring_coords(linestring_coords_a, anchor_xy) + coords_a = align_linestring_coords(linestring_coords_a, anchor_xy) coords_b = linestring_coords_b # case C: merge in the b -> a order (saves flipping both) elif np.allclose(linestring_coords_a[0][:2], linestring_coords_b[-1][:2], atol=tolerance, rtol=0): @@ -661,14 +701,14 @@ def nx_remove_filler_nodes(nx_multigraph: MultiGraph) -> MultiGraph: ) if not np.allclose(agg_geom[0], s_xy, atol=config.ATOL, rtol=config.RTOL): raise ValueError("New Linestring geometry does not match starting node coordinates.") - agg_geom = _snap_linestring_startpoint(agg_geom, s_xy) + agg_geom = snap_linestring_startpoint(agg_geom, s_xy) e_xy: CoordsType = ( cast(float, nx_multigraph.nodes[end_nd]["x"]), cast(float, nx_multigraph.nodes[end_nd]["y"]), ) if not np.allclose(agg_geom[-1], e_xy, atol=config.ATOL, rtol=config.RTOL): raise ValueError("New Linestring geometry does not match ending node coordinates.") - agg_geom = _snap_linestring_endpoint(agg_geom, e_xy) + agg_geom = snap_linestring_endpoint(agg_geom, e_xy) # create a new linestring new_geom = geometry.LineString(agg_geom) if new_geom.type != "LineString": @@ -822,12 +862,12 @@ def _squash_adjacent( cast(float, nx_multigraph.nodes[nd_key]["x"]), cast(float, nx_multigraph.nodes[nd_key]["y"]), ) - line_coords = _align_linestring_coords(line_geom.coords, s_xy) + line_coords = align_linestring_coords(line_geom.coords, s_xy) # update geom starting point to new parent node's coordinates - line_coords = _snap_linestring_startpoint(line_coords, (c.x, c.y)) # pylint: disable=no-member + line_coords = snap_linestring_startpoint(line_coords, (c.x, c.y)) # pylint: disable=no-member # if self-loop, then the end also needs updating if nd_key == nb_nd_key: - line_coords = _snap_linestring_endpoint(line_coords, (c.x, c.y)) # pylint: disable=no-member + line_coords = snap_linestring_endpoint(line_coords, (c.x, c.y)) # pylint: disable=no-member target_nd_key = new_nd_name else: target_nd_key = nb_nd_key @@ -870,20 +910,41 @@ def _squash_adjacent( return nx_multigraph -def _merge_parallel_edges( +def merge_parallel_edges( nx_multigraph: MultiGraph, merge_edges_by_midline: bool, multi_edge_len_factor: float, multi_edge_min_len: float, ) -> MultiGraph: """ - Check a MultiGraph for duplicate edges; which, if found, will be consolidated. + Check a MultiGraph for duplicate edges; which, if found, will be merged. + + The merging of nodes creates parallel edges which may start and end at a shared node on either side. These edges + are replaced by a single new edge, with the new geometry selected from either: + - An imaginary centreline of the combined edges if `merge_edges_by_midline` is set to `True`; + - Else, the shortest edge, with longer edges discarded; + - Note that substantially longer parallel edges are retained, instead of discarded, if they exceed + `multi_edge_len_factor` and are longer than `multi_edge_min_len`. + + Parameters + ---------- + nx_multigraph: MultiGraph + A `networkX` `MultiGraph` in a projected coordinate system, containing `x` and `y` node attributes, and `geom` + edge attributes containing `LineString` geoms. + merge_edges_by_midline: bool + Whether to merge parallel edges by an imaginary centreline. If set to False, then the shortest edge will be + retained as the new geometry and the longer edges will be discarded. Defaults to True. + multi_edge_len_factor: float + In cases where one line is significantly longer than another (e.g. crescent streets) then the longer edge is + retained as separate if exceeding the multi_edge_len_factor as a factor of the shortest length but with the + exception that (longer) edges still shorter than multi_edge_min_len are removed regardless. Defaults to 1.5. + multi_edge_min_len: float + See `multi_edge_len_factor`. Defaults to 100. - If merge_edges_by_midline is False, then the shortest of the edges is used and the others are simply dropped. - If merge_edges_by_midline is True, then the duplicates are replaced with a new edge following the merged centreline. - In cases where one line is significantly longer than another (e.g. a crescent streets), - then the longer edge is retained as separate if exceeding the multi_edge_len_factor as a factor of the shortest - length but with the exception that (longer) edges still shorter than multi_edge_min_len are removed regardless. + Returns + ------- + MultiGraph + A `networkX` `MultiGraph` with consolidated nodes. """ if not isinstance(nx_multigraph, nx.MultiGraph): # type: ignore @@ -1200,7 +1261,7 @@ def recursive_squash( # remove filler nodes deduped_graph = nx_remove_filler_nodes(_multi_graph) # remove any parallel edges that may have resulted from squashing nodes - deduped_graph = _merge_parallel_edges( + deduped_graph = merge_parallel_edges( deduped_graph, merge_edges_by_midline, multi_edge_len_factor, multi_edge_min_len ) @@ -1407,7 +1468,7 @@ def recurse_child_keys( if _multi_graph.has_edge(start_nd_key, end_nd_key, edge_idx): _multi_graph.remove_edge(start_nd_key, end_nd_key, edge_idx) # squashing nodes can result in edge duplicates - deduped_graph = _merge_parallel_edges( + deduped_graph = merge_parallel_edges( _multi_graph, merge_edges_by_midline, multi_edge_len_factor, multi_edge_min_len ) @@ -1576,7 +1637,7 @@ def nx_decompose(nx_multigraph: MultiGraph, decompose_max: float) -> MultiGraph: f"edge {start_nd_key}-{end_nd_key}." ) # check geom coordinates directionality - flip if facing backwards direction - line_geom_coords = _align_linestring_coords(line_geom.coords, (s_x, s_y)) + line_geom_coords = align_linestring_coords(line_geom.coords, (s_x, s_y)) # double check that coordinates now face the forwards direction if not np.allclose((s_x, s_y), line_geom_coords[0][:2], atol=config.ATOL, rtol=config.RTOL) or not np.allclose( (e_x, e_y), line_geom_coords[-1][:2], atol=config.ATOL, rtol=config.RTOL @@ -1712,7 +1773,7 @@ def get_half_geoms(nx_multigraph_ref: MultiGraph, a_node: NodeKey, b_node: NodeK f"Expecting LineString geometry but found {line_geom.type} geometry for edge {a_node}-{b_node}." ) # align geom coordinates to start from A side - line_geom_coords = _align_linestring_coords(line_geom.coords, a_xy) + line_geom_coords = align_linestring_coords(line_geom.coords, a_xy) line_geom = geometry.LineString(line_geom_coords) # generate the two half geoms a_half_geom: geometry.LineString = ops.substring(line_geom, 0, line_geom.length / 2) # type: ignore @@ -1729,12 +1790,12 @@ def get_half_geoms(nx_multigraph_ref: MultiGraph, a_node: NodeKey, b_node: NodeK raise ValueError("Nodes of half geoms don't match") # snap to prevent creeping tolerance issues # A side geom starts at node A and ends at new midpoint - a_half_geom_coords = _snap_linestring_startpoint(a_half_geom.coords, a_xy) + a_half_geom_coords = snap_linestring_startpoint(a_half_geom.coords, a_xy) # snap new midpoint to geom A's endpoint (i.e. no need to snap endpoint of geom A) mid_xy = a_half_geom_coords[-1][:2] # B side geom starts at mid and ends at B node - b_half_geom_coords = _snap_linestring_startpoint(b_half_geom.coords, mid_xy) - b_half_geom_coords = _snap_linestring_endpoint(b_half_geom_coords, b_xy) + b_half_geom_coords = snap_linestring_startpoint(b_half_geom.coords, mid_xy) + b_half_geom_coords = snap_linestring_endpoint(b_half_geom_coords, b_xy) # double check coords if ( a_half_geom_coords[0][:2] != a_xy @@ -1909,7 +1970,7 @@ def network_structure_from_nx( ) # check geom coordinates directionality (for bearings at index 5 / 6) # flip if facing backwards direction - line_geom_coords = _align_linestring_coords(line_geom.coords, (node_x, node_y)) + line_geom_coords = align_linestring_coords(line_geom.coords, (node_x, node_y)) # iterate the coordinates and calculate the angular change angle_sum = _measure_cumulative_angle(line_geom_coords) if not np.isfinite(angle_sum) or angle_sum < 0: @@ -2077,128 +2138,3 @@ def nx_from_network_structure( g_multi_copy.nodes[nd_key][metrics_column_label] = node_row[metrics_column_label] return g_multi_copy - - -def nx_from_osm_nx( - nx_multidigraph: MultiDiGraph, - node_attributes: Optional[Union[list[str], tuple[str]]] = None, - edge_attributes: Optional[Union[list[str], tuple[str]]] = None, - tolerance: float = config.ATOL, -) -> MultiGraph: - """ - Copy an [`OSMnx`](https://osmnx.readthedocs.io/) directed `MultiDiGraph` to an undirected `cityseer` `MultiGraph`. - - See the [`OSMnx`](/guide#osm-and-networkx) section of the guide for a more general discussion (and example) on - workflows combining `OSMnx` with `cityseer`. - - `x` and `y` node attributes will be copied directly and `geometry` edge attributes will be copied to a `geom` edge - attribute. The conversion process will snap the `shapely` `LineString` endpoints to the corresponding start and end - node coordinates. - - Note that `OSMnx` `geometry` attributes only exist for simplified edges: if a `geometry` edge attribute is not - found, then a simple (straight) `shapely` `LineString` geometry will be inferred from the respective start and end - nodes. - - Other attributes will be ignored to avoid potential downstream misinterpretations of the attributes as a consequence - of subsequent steps of graph manipulation, i.e. to avoid situations where attributes may fall out of lock-step with - the state of the graph. If particular attributes need to be copied across, and assuming cognisance of downstream - implications, then these can be manually specified by providing a list of node attributes keys per the - `node_attributes` parameter or edge attribute keys per the `edge_attributes` parameter. - - Parameters - ---------- - nx_multidigraph: MultiDiGraph - A `OSMnx` derived `networkX` `MultiDiGraph` containing `x` and `y` node attributes, with optional `geometry` - edge attributes containing `LineString` geoms (for simplified edges). - node_attributes: tuple[str] - Optional node attributes to copy to the new MultiGraph. (In addition to the default `x` and `y` attributes.) - edge_attributes: tuple[str] - Optional edge attributes to copy to the new MultiGraph. (In addition to the optional `geometry` attribute.) - tolerance: float - Tolerance at which to raise errors for mismatched geometry end-points vis-a-vis corresponding node coordinates. - Prior to conversion, this method will check edge geometry end-points for alignment with the corresponding - end-point nodes. Where these don't align within the given tolerance an exception will be raised. Otherwise, if - within the tolerance, the conversion function will snap the geometry end-points to the corresponding node - coordinates so that downstream exceptions are not subsequently raised. It is preferable to minimise graph - manipulation prior to conversion to a `cityseer` compatible `MultiGraph` otherwise particularly large tolerances - may be required, and this may lead to some unexpected or undesirable effects due to aggressive snapping. - - Returns - ------- - MultiGraph - A `cityseer` compatible `networkX` graph with `x` and `y` node attributes and `geom` edge attribute. - - """ - if not isinstance(nx_multidigraph, nx.MultiDiGraph): # type: ignore - raise TypeError("This method requires a directed networkX MultiDiGraph as derived from `OSMnx`.") - if node_attributes is not None and not isinstance(node_attributes, (list, tuple)): - raise TypeError("Node attributes to be copied should be provided as either a list or tuple of attribute keys.") - if edge_attributes is not None and not isinstance(edge_attributes, (list, tuple)): - raise TypeError("Edge attributes to be copied should be provided as either a list or tuple of attribute keys.") - logger.info("Converting OSMnx MultiDiGraph to cityseer MultiGraph.") - # target MultiGraph - g_multi: MultiGraph = nx.MultiGraph() # type: ignore - - def _process_node(nd_key: NodeKey) -> tuple[float, float]: - # x - if "x" not in nx_multidigraph.nodes[nd_key]: - raise KeyError(f'Encountered node missing "x" coordinate attribute for node {nd_key}.') - x: float = nx_multidigraph.nodes[nd_key]["x"] - # y - if "y" not in nx_multidigraph.nodes[nd_key]: - raise KeyError(f'Encountered node missing "y" coordinate attribute for node {nd_key}.') - y: float = nx_multidigraph.nodes[nd_key]["y"] - # add attributes if necessary - if nd_key not in g_multi: - g_multi.add_node(nd_key, x=x, y=y) - if node_attributes is not None: - for node_att in node_attributes: - if node_att not in nx_multidigraph.nodes[nd_key]: - raise ValueError(f"Specified attribute {node_att} is not available for node {nd_key}.") - g_multi.nodes[nd_key][node_att] = nx_multidigraph.nodes[nd_key][node_att] - - return x, y - - # copy nodes and edges - start_nd_key: NodeKey - end_nd_key: NodeKey - edge_idx: int - edge_data: EdgeData - for start_nd_key, end_nd_key, edge_idx, edge_data in tqdm( # type: ignore - nx_multidigraph.edges(data=True, keys=True), disable=config.QUIET_MODE - ): - edge_data = cast(EdgeData, edge_data) # type: ignore - s_x, s_y = _process_node(start_nd_key) - e_x, e_y = _process_node(end_nd_key) - # copy edge if present - if "geometry" in edge_data: - line_geom: geometry.LineString = edge_data["geometry"] - # otherwise create - else: - line_geom = geometry.LineString([[s_x, s_y], [e_x, e_y]]) - # check for LineString validity - if line_geom.type != "LineString": - raise TypeError( - f"Expecting LineString geometry but found {line_geom.type} geometry for " - f"edge {start_nd_key}-{end_nd_key}." - ) - # orient LineString - geom_coords = line_geom.coords - if not np.allclose((s_x, s_y), geom_coords[0][:2], atol=tolerance, rtol=0): - geom_coords = _align_linestring_coords(geom_coords, (s_x, s_y)) - # check starting and ending tolerances - if not np.allclose((s_x, s_y), geom_coords[0][:2], atol=tolerance, rtol=0): - raise ValueError("Starting node coordinates don't match LineString geometry starting coordinates.") - if not np.allclose((e_x, e_y), geom_coords[-1][:2], atol=tolerance, rtol=0): - raise ValueError("Ending node coordinates don't match LineString geometry ending coordinates.") - # snap starting and ending coords to avoid rounding error issues - geom_coords = _snap_linestring_startpoint(geom_coords, (s_x, s_y)) - geom_coords = _snap_linestring_endpoint(geom_coords, (e_x, e_y)) - g_multi.add_edge(start_nd_key, end_nd_key, key=edge_idx, geom=geometry.LineString(geom_coords)) - if edge_attributes is not None: - for edge_att in edge_attributes: - if edge_att not in edge_data: - raise ValueError(f"Attribute {edge_att} is not available for edge {start_nd_key}-{end_nd_key}.") - g_multi[start_nd_key][end_nd_key][edge_idx][edge_att] = edge_data[edge_att] - - return g_multi diff --git a/cityseer/tools/io.py b/cityseer/tools/io.py new file mode 100644 index 00000000..58f6884e --- /dev/null +++ b/cityseer/tools/io.py @@ -0,0 +1,438 @@ +""" +Functions for fetching and cleaning OSM data. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Optional, Union, cast + +import fiona +import networkx as nx +import numpy as np +import requests +import utm +from shapely import geometry +from tqdm import tqdm + +from cityseer import config +from cityseer.tools import graphs +from cityseer.tools.graphs import EdgeData, MultiDiGraph, NodeKey + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# type hack until networkx supports type-hinting +MultiGraph = Any + + +def buffered_point_poly(lng: float, lat: float, buffer: int) -> tuple[geometry.Polygon, geometry.Polygon, int, str]: + """ + Buffer a point and return a `shapely` Polygon in WGS and UTM coordinates. + + This function can be used to prepare a `poly_wgs` `Polygon` for passing to + [`osm_graph_from_poly_wgs()`](#osm_graph_from_poly_wgs). + + Parameters + ---------- + lng: float + The longitudinal WGS coordinate in degrees. + lat: float + The latitudinal WGS coordinate in degrees. + buffer: int + The buffer distance in metres. + + Returns + ------- + poly_wgs: Polygon + A `shapely` `Polygon` in WGS coordinates. + poly_utm: Polygon + A `shapely` `Polygon` in UTM coordinates. + utm_zone_number: int + The UTM zone number used for conversion. + utm_zone_letter: str + The UTM zone letter used for conversion. + + """ + # cast the WGS coordinates to UTM prior to buffering + easting, northing, utm_zone_number, utm_zone_letter = utm.from_latlon(lat, lng) + logger.info(f"UTM conversion info: UTM zone number: {utm_zone_number}, UTM zone letter: {utm_zone_letter}") + # create a point, and then buffer + pnt = geometry.Point(easting, northing) + poly_utm: geometry.Polygon = pnt.buffer(buffer) # type: ignore + # convert back to WGS + # the polygon is too big for the OSM server, so have to use convex hull then later prune + geom = [ + utm.to_latlon(east, north, utm_zone_number, utm_zone_letter) # type: ignore + for east, north in poly_utm.convex_hull.exterior.coords # type: ignore # pylint: disable=no-member + ] + poly_wgs = geometry.Polygon(geom) + + return poly_wgs, poly_utm, utm_zone_number, utm_zone_letter + + +def fetch_osm_network(osm_request: str, timeout: int = 30, max_tries: int = 3) -> Optional[requests.Response]: + """ + Fetches an OSM response. + + Parameters + ---------- + osm_request: str + A valid OSM request as a string. Use + [OSM Overpass](https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL) for testing custom queries. + timeout: int + Timeout duration for API call in seconds. + max_tries: int + The number of attempts to fetch a response before raising, by default 3 + + Returns + ------- + requests.Response + An OSM API response. + + """ + osm_response: Optional[requests.Response] = None + while max_tries: + osm_response = requests.get( + "https://overpass-api.de/api/interpreter", + timeout=timeout, + params={"data": osm_request}, + ) + # break if OK response + if osm_response is not None and osm_response.status_code == 200: + break + # otherwise try until max_tries is exhausted + logger.warning("Unsuccessful OSM API request response, trying again...") + max_tries -= 1 + + if osm_response is None or not osm_response.status_code == 200: + raise requests.RequestException("Unsuccessful OSM API request.") + + return osm_response + + +def osm_graph_from_poly_wgs( + poly_wgs: geometry.Polygon, + custom_request: Optional[str] = None, + simplify: bool = True, + remove_parallel: bool = True, + iron_edges: bool = True, +) -> MultiGraph: # noqa + """ + + Prepares a `networkX` `MultiGraph` from an OSM request for a buffered region around a given `lng` and `lat` + parameter. + + Parameters + ---------- + poly_wgs: shapely.Polygon + A shapely Polygon representing the extents for which to fetch the OSM network. Must be in WGS (EPSG 4326) + coordinates. + custom_request: str + An optional custom OSM request. None by default. If provided, this must include a "geom_osm" string formatting + key for inserting the geometry passed to the OSM API query. See the discussion below. + simplify: bool + Whether to automatically simplify the OSM graph. True by default. Set to False for manual cleaning. + remove_parallel: bool + Whether to remove parallel roadway segments. True by default. Only has an effect if `simplify` is `True`. + iron_edges: bool + Whether to straighten the ends of street segments. This can help to reduce the number of artefacts from + segment kinks from merging `LineStrings`. Only has an effect if `simplify` is `True`. + + Returns + ------- + MultiGraph + A `networkX` `MultiGraph` with `x` and `y` node attributes that have been converted to UTM. The network will be + simplified if the `simplify` parameter is `True`. + + Examples + -------- + The default OSM request will attempt to find all walkable routes. It will ignore motorways and will try to work with + pedestrianised routes and walkways. + + If you wish to provide your own OSM request, then provide a valid OSM API request as a string. The string must + contain a `{geom_osm}` string formatting key. This allows for the geometry parameter passed to the OSM API to be + injected into the request. It is also recommended to not use the `skel` output option so that `cityseer` can use + street name and highway reference information for cleaning purposes. See + [OSM Overpass](https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL) for experimenting with custom queries. + + For example, to return only drivable roads, then use a request similar to the following. Notice the `{geom_osm}` + formatting key and the use of `out qt;` instead of `out skel qt;`. + + ```python + custom_request = f''' + [out:json]; + ( + way["highway"] + ["area"!="yes"] + ["highway"!~"footway|pedestrian|steps|bus_guideway|escape|raceway|proposed|planned|abandoned|platform|construction"] + (poly:"{geom_osm}"); + ); + out body; + >; + out qt; + ''' + ``` + + """ + # format for OSM query + geom_osm = str.join(" ", [f"{lat} {lng}" for lat, lng in poly_wgs.exterior.coords]) # type: ignore + if custom_request is not None: + if "geom_osm" not in custom_request: + raise ValueError( + 'The provided custom_request does not contain a "geom_osm" formatting key, i.e. (poly:"{geom_osm}") ' + "This key is required for interpolating the generated geometry into the request." + ) + request = custom_request.format(geom_osm=geom_osm) + else: + request = f""" + /* https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL */ + [out:json]; + ( + way["highway"] + ["area"!="yes"] + ["highway"!~"motorway|motorway_link|bus_guideway|escape|raceway|proposed|planned|abandoned|platform|construction"] + ["service"!~"parking_aisle"] + ["amenity"!~"charging_station|parking|fuel|motorcycle_parking|parking_entrance|parking_space"] + ["access"!~"private|customers"] + ["indoor"!="yes"] + (poly:"{geom_osm}"); + ); + out body; + >; + out qt; + """ + # generate the query + osm_response = fetch_osm_network(request) + # build graph + graph_wgs = graphs.nx_from_osm(osm_json=osm_response.text) # type: ignore + # cast to UTM + graph_utm = graphs.nx_wgs_to_utm(graph_wgs) + # simplify + if simplify: + graph_utm = graphs.nx_simple_geoms(graph_utm) + graph_utm = graphs.nx_remove_filler_nodes(graph_utm) + graph_utm = graphs.nx_remove_dangling_nodes(graph_utm, despine=20, remove_disconnected=True) + graph_utm = graphs.nx_remove_filler_nodes(graph_utm) + graph_utm = graphs.nx_consolidate_nodes( + graph_utm, crawl=True, buffer_dist=10, min_node_group=3, cent_min_degree=4, cent_min_names=4 + ) + + if remove_parallel: + graph_utm = graphs.nx_split_opposing_geoms(graph_utm, buffer_dist=15) + graph_utm = graphs.nx_consolidate_nodes( + graph_utm, buffer_dist=15, crawl=False, min_node_degree=2, cent_min_degree=4, cent_min_names=4 + ) + graph_utm = graphs.nx_remove_filler_nodes(graph_utm) + + if iron_edges: + graph_utm = graphs.nx_iron_edge_ends(graph_utm) + + return graph_utm + + +def nx_from_osm_nx( + nx_multidigraph: MultiDiGraph, + node_attributes: Optional[Union[list[str], tuple[str]]] = None, + edge_attributes: Optional[Union[list[str], tuple[str]]] = None, + tolerance: float = config.ATOL, +) -> MultiGraph: + """ + Copy an [`OSMnx`](https://osmnx.readthedocs.io/) directed `MultiDiGraph` to an undirected `cityseer` `MultiGraph`. + + See the [`OSMnx`](/guide#osm-and-networkx) section of the guide for a more general discussion (and example) on + workflows combining `OSMnx` with `cityseer`. + + `x` and `y` node attributes will be copied directly and `geometry` edge attributes will be copied to a `geom` edge + attribute. The conversion process will snap the `shapely` `LineString` endpoints to the corresponding start and end + node coordinates. + + Note that `OSMnx` `geometry` attributes only exist for simplified edges: if a `geometry` edge attribute is not + found, then a simple (straight) `shapely` `LineString` geometry will be inferred from the respective start and end + nodes. + + Other attributes will be ignored to avoid potential downstream misinterpretations of the attributes as a consequence + of subsequent steps of graph manipulation, i.e. to avoid situations where attributes may fall out of lock-step with + the state of the graph. If particular attributes need to be copied across, and assuming cognisance of downstream + implications, then these can be manually specified by providing a list of node attributes keys per the + `node_attributes` parameter or edge attribute keys per the `edge_attributes` parameter. + + Parameters + ---------- + nx_multidigraph: MultiDiGraph + A `OSMnx` derived `networkX` `MultiDiGraph` containing `x` and `y` node attributes, with optional `geometry` + edge attributes containing `LineString` geoms (for simplified edges). + node_attributes: tuple[str] + Optional node attributes to copy to the new MultiGraph. (In addition to the default `x` and `y` attributes.) + edge_attributes: tuple[str] + Optional edge attributes to copy to the new MultiGraph. (In addition to the optional `geometry` attribute.) + tolerance: float + Tolerance at which to raise errors for mismatched geometry end-points vis-a-vis corresponding node coordinates. + Prior to conversion, this method will check edge geometry end-points for alignment with the corresponding + end-point nodes. Where these don't align within the given tolerance an exception will be raised. Otherwise, if + within the tolerance, the conversion function will snap the geometry end-points to the corresponding node + coordinates so that downstream exceptions are not subsequently raised. It is preferable to minimise graph + manipulation prior to conversion to a `cityseer` compatible `MultiGraph` otherwise particularly large tolerances + may be required, and this may lead to some unexpected or undesirable effects due to aggressive snapping. + + Returns + ------- + MultiGraph + A `cityseer` compatible `networkX` graph with `x` and `y` node attributes and `geom` edge attributes. + + """ + if not isinstance(nx_multidigraph, nx.MultiDiGraph): # type: ignore + raise TypeError("This method requires a directed networkX MultiDiGraph as derived from `OSMnx`.") + if node_attributes is not None and not isinstance(node_attributes, (list, tuple)): + raise TypeError("Node attributes to be copied should be provided as either a list or tuple of attribute keys.") + if edge_attributes is not None and not isinstance(edge_attributes, (list, tuple)): + raise TypeError("Edge attributes to be copied should be provided as either a list or tuple of attribute keys.") + logger.info("Converting OSMnx MultiDiGraph to cityseer MultiGraph.") + # target MultiGraph + g_multi: MultiGraph = nx.MultiGraph() # type: ignore + + def _process_node(nd_key: NodeKey) -> tuple[float, float]: + # x + if "x" not in nx_multidigraph.nodes[nd_key]: + raise KeyError(f'Encountered node missing "x" coordinate attribute for node {nd_key}.') + x: float = nx_multidigraph.nodes[nd_key]["x"] + # y + if "y" not in nx_multidigraph.nodes[nd_key]: + raise KeyError(f'Encountered node missing "y" coordinate attribute for node {nd_key}.') + y: float = nx_multidigraph.nodes[nd_key]["y"] + # add attributes if necessary + if nd_key not in g_multi: + g_multi.add_node(nd_key, x=x, y=y) + if node_attributes is not None: + for node_att in node_attributes: + if node_att not in nx_multidigraph.nodes[nd_key]: + raise ValueError(f"Specified attribute {node_att} is not available for node {nd_key}.") + g_multi.nodes[nd_key][node_att] = nx_multidigraph.nodes[nd_key][node_att] + + return x, y + + # copy nodes and edges + start_nd_key: NodeKey + end_nd_key: NodeKey + edge_idx: int + edge_data: EdgeData + for start_nd_key, end_nd_key, edge_idx, edge_data in tqdm( # type: ignore + nx_multidigraph.edges(data=True, keys=True), disable=config.QUIET_MODE + ): + edge_data = cast(EdgeData, edge_data) # type: ignore + s_x, s_y = _process_node(start_nd_key) + e_x, e_y = _process_node(end_nd_key) + # copy edge if present + if "geometry" in edge_data: + line_geom: geometry.LineString = edge_data["geometry"] + # otherwise create + else: + line_geom = geometry.LineString([[s_x, s_y], [e_x, e_y]]) + # check for LineString validity + if line_geom.type != "LineString": + raise TypeError( + f"Expecting LineString geometry but found {line_geom.type} geometry for " + f"edge {start_nd_key}-{end_nd_key}." + ) + # orient LineString + geom_coords = line_geom.coords + if not np.allclose((s_x, s_y), geom_coords[0][:2], atol=tolerance, rtol=0): + geom_coords = graphs.align_linestring_coords(geom_coords, (s_x, s_y)) + # check starting and ending tolerances + if not np.allclose((s_x, s_y), geom_coords[0][:2], atol=tolerance, rtol=0): + raise ValueError("Starting node coordinates don't match LineString geometry starting coordinates.") + if not np.allclose((e_x, e_y), geom_coords[-1][:2], atol=tolerance, rtol=0): + raise ValueError("Ending node coordinates don't match LineString geometry ending coordinates.") + # snap starting and ending coords to avoid rounding error issues + geom_coords = graphs.snap_linestring_startpoint(geom_coords, (s_x, s_y)) + geom_coords = graphs.snap_linestring_endpoint(geom_coords, (e_x, e_y)) + g_multi.add_edge(start_nd_key, end_nd_key, key=edge_idx, geom=geometry.LineString(geom_coords)) + if edge_attributes is not None: + for edge_att in edge_attributes: + if edge_att not in edge_data: + raise ValueError(f"Attribute {edge_att} is not available for edge {start_nd_key}-{end_nd_key}.") + g_multi[start_nd_key][end_nd_key][edge_idx][edge_att] = edge_data[edge_att] + + return g_multi + + +def nx_from_open_roads( + open_roads_path: Union[str, Path], + target_bbox: Optional[Union[tuple[int, int, int, int], tuple[float, float, float, float]]] = None, +) -> nx.MultiGraph: + """ + Generates a `networkX` `MultiGraph` from an OS Open Roads dataset. + + Parameters + ---------- + open_roads_path: str | Path + A valid relative filepath from which to load the OS Open Roads dataset. + target_bbox: tuple[int] + A tuple of integers or floats representing the bounding box extents for which to load the dataset. Set to + `None` for no bounding box. By default None. + + Returns + ------- + MultiGraph + A `cityseer` compatible `networkX` graph with `x` and `y` node attributes and `geom` edge attributes. + + """ + # create a networkX multigraph + g_multi = nx.MultiGraph() + + # load the nodes + with fiona.open(open_roads_path, layer="RoadNode") as nodes: # type: ignore + for node_data in nodes.values(bbox=target_bbox): # type: ignore + node_id: str = node_data["properties"]["id"] + x: float + y: float + x, y = node_data["geometry"]["coordinates"] + g_multi.add_node(node_id, x=x, y=y) # type: ignore + + # load the edges + n_dropped = 0 + with fiona.open(open_roads_path, layer="RoadLink") as edges: # type: ignore + for edge_data in edges.values(bbox=target_bbox): # type: ignore + # x, y = edge_data['geometry']['coordinates'] + props: dict = edge_data["properties"] # type: ignore + start_nd: str = props["startNode"] + end_nd: str = props["endNode"] + names: set[str] = set() + for name_key in ["name1", "name2"]: + name: str | None = props[name_key] + if name is not None: + names.add(name) + refs: set[str] = set() + for ref_key in ["roadClassificationNumber"]: + ref: str | None = props[ref_key] + if ref is not None: + refs.add(ref) + highways: set[str] = set() + for highway_key in ["roadFunction", "roadClassification"]: # 'formOfWay' + highway: str = props[highway_key] + if highway is not None: + highways.add(highway) + if props["trunkRoad"]: + highways.add("IsTrunk") + if props["primaryRoute"]: + highways.add("IsPrimary") + # filter out unwanted highway tags + highways.difference_update({"Not Classified", "Unclassified", "Unknown", "Restricted Local Access Road"}) + # create the geometry + geom = geometry.LineString(edge_data["geometry"]["coordinates"]) # type: ignore + geom = geom.simplify(5) + # do not add edges to clipped extents + if start_nd not in g_multi or end_nd not in g_multi: + n_dropped += 1 + continue + g_multi.add_edge(start_nd, end_nd, names=list(names), refs=list(refs), highways=list(highways), geom=geom) + + logger.info(f"Nodes: {g_multi.number_of_nodes()}") + logger.info(f"Edges: {g_multi.number_of_edges()}") + logger.info(f"Dropped {n_dropped} edges where not both start and end nodes were present.") + logger.info("Running basic graph cleaning") + g_multi = graphs.nx_remove_filler_nodes(g_multi) + g_multi = graphs.merge_parallel_edges(g_multi, True, 1.5, 100) + + return g_multi diff --git a/cityseer/tools/mock.py b/cityseer/tools/mock.py index 8d957951..108e13b4 100644 --- a/cityseer/tools/mock.py +++ b/cityseer/tools/mock.py @@ -325,7 +325,7 @@ def mock_landuse_categorical_data( """ np.random.seed(seed=random_seed) - random_class_str = string.ascii_lowercase + random_class_str: list[str] = list(string.ascii_lowercase) if num_classes > len(random_class_str): raise ValueError( f"The requested {num_classes} classes exceeds max available categorical classes of {len(random_class_str)}" diff --git a/cityseer/tools/osm.py b/cityseer/tools/osm.py deleted file mode 100644 index 8cf8b15d..00000000 --- a/cityseer/tools/osm.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -Functions for fetching and cleaning OSM data. -""" -from __future__ import annotations - -import logging -from typing import Any, Optional - -import requests -import utm -from shapely import geometry - -from cityseer.tools import graphs - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -# type hack until networkx supports type-hinting -MultiGraph = Any - - -def buffered_point_poly(lng: float, lat: float, buffer: int) -> tuple[geometry.Polygon, geometry.Polygon, int, str]: - """ - Buffer a point and return a `shapely` Polygon in WGS and UTM coordinates. - - This function can be used to prepare a `poly_wgs` `Polygon` for passing to - [`osm_graph_from_poly_wgs()`](#osm_graph_from_poly_wgs). - - Parameters - ---------- - lng: float - The longitudinal WGS coordinate in degrees. - lat: float - The latitudinal WGS coordinate in degrees. - buffer: int - The buffer distance in metres. - - Returns - ------- - poly_wgs: Polygon - A `shapely` `Polygon` in WGS coordinates. - poly_utm: Polygon - A `shapely` `Polygon` in UTM coordinates. - utm_zone_number: int - The UTM zone number used for conversion. - utm_zone_letter: str - The UTM zone letter used for conversion. - - """ - # cast the WGS coordinates to UTM prior to buffering - easting, northing, utm_zone_number, utm_zone_letter = utm.from_latlon(lat, lng) - logger.info(f"UTM conversion info: UTM zone number: {utm_zone_number}, UTM zone letter: {utm_zone_letter}") - # create a point, and then buffer - pnt = geometry.Point(easting, northing) - poly_utm: geometry.Polygon = pnt.buffer(buffer) # type: ignore - # convert back to WGS - # the polygon is too big for the OSM server, so have to use convex hull then later prune - geom = [ - utm.to_latlon(east, north, utm_zone_number, utm_zone_letter) # type: ignore - for east, north in poly_utm.convex_hull.exterior.coords # type: ignore # pylint: disable=no-member - ] - poly_wgs = geometry.Polygon(geom) - - return poly_wgs, poly_utm, utm_zone_number, utm_zone_letter - - -def fetch_osm_network(osm_request: str, timeout: int = 30, max_tries: int = 3) -> Optional[requests.Response]: - """ - Fetches an OSM response. - - Parameters - ---------- - osm_request: str - A valid OSM request as a string. Use - [OSM Overpass](https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL) for testing custom queries. - timeout: int - Timeout duration for API call in seconds. - max_tries: int - The number of attempts to fetch a response before raising, by default 3 - - Returns - ------- - requests.Response - An OSM API response. - - """ - osm_response: Optional[requests.Response] = None - while max_tries: - osm_response = requests.get( - "https://overpass-api.de/api/interpreter", - timeout=timeout, - params={"data": osm_request}, - ) - # break if OK response - if osm_response is not None and osm_response.status_code == 200: - break - # otherwise try until max_tries is exhausted - logger.warning("Unsuccessful OSM API request response, trying again...") - max_tries -= 1 - - if osm_response is None or not osm_response.status_code == 200: - raise requests.RequestException("Unsuccessful OSM API request.") - - return osm_response - - -def osm_graph_from_poly_wgs( - poly_wgs: geometry.Polygon, - custom_request: Optional[str] = None, - simplify: bool = True, - remove_parallel: bool = True, - iron_edges: bool = True, -) -> MultiGraph: # noqa - """ - - Prepares a `networkX` `MultiGraph` from an OSM request for a buffered region around a given `lng` and `lat` - parameter. - - Parameters - ---------- - poly_wgs: shapely.Polygon - A shapely Polygon representing the extents for which to fetch the OSM network. Must be in WGS (EPSG 4326) - coordinates. - custom_request: str - An optional custom OSM request. None by default. If provided, this must include a "geom_osm" string formatting - key for inserting the geometry passed to the OSM API query. See the discussion below. - simplify: bool - Whether to automatically simplify the OSM graph. True by default. Set to False for manual cleaning. - remove_parallel: bool - Whether to remove parallel roadway segments. True by default. Only has an effect if `simplify` is `True`. - iron_edges: bool - Whether to straighten the ends of street segments. This can help to reduce the number of artefacts from - segment kinks from merging `LineStrings`. Only has an effect if `simplify` is `True`. - - Returns - ------- - MultiGraph - A `networkX` `MultiGraph` with `x` and `y` node attributes that have been converted to UTM. The network will be - simplified if the `simplify` parameter is `True`. - - Examples - -------- - The default OSM request will attempt to find all walkable routes. It will ignore motorways and will try to work with - pedestrianised routes and walkways. - - If you wish to provide your own OSM request, then provide a valid OSM API request as a string. The string must - contain a `{geom_osm}` string formatting key. This allows for the geometry parameter passed to the OSM API to be - injected into the request. It is also recommended to not use the `skel` output option so that `cityseer` can use - street name and highway reference information for cleaning purposes. See - [OSM Overpass](https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL) for experimenting with custom queries. - - For example, to return only drivable roads, then use a request similar to the following. Notice the `{geom_osm}` - formatting key and the use of `out qt;` instead of `out skel qt;`. - - ```python - custom_request = f''' - [out:json]; - ( - way["highway"] - ["area"!="yes"] - ["highway"!~"footway|pedestrian|steps|bus_guideway|escape|raceway|proposed|planned|abandoned|platform|construction"] - (poly:"{geom_osm}"); - ); - out body; - >; - out qt; - ''' - ``` - - """ - # format for OSM query - geom_osm = str.join(" ", [f"{lat} {lng}" for lat, lng in poly_wgs.exterior.coords]) # type: ignore - if custom_request is not None: - if "geom_osm" not in custom_request: - raise ValueError( - 'The provided custom_request does not contain a "geom_osm" formatting key, i.e. (poly:"{geom_osm}") ' - "This key is required for interpolating the generated geometry into the request." - ) - request = custom_request.format(geom_osm=geom_osm) - else: - request = f""" - /* https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL */ - [out:json]; - ( - way["highway"] - ["area"!="yes"] - ["highway"!~"motorway|motorway_link|bus_guideway|escape|raceway|proposed|planned|abandoned|platform|construction"] - ["service"!~"parking_aisle"] - ["amenity"!~"charging_station|parking|fuel|motorcycle_parking|parking_entrance|parking_space"] - ["access"!~"private|customers"] - ["indoor"!="yes"] - (poly:"{geom_osm}"); - ); - out body; - >; - out qt; - """ - # generate the query - osm_response = fetch_osm_network(request) - # build graph - graph_wgs = graphs.nx_from_osm(osm_json=osm_response.text) # type: ignore - # cast to UTM - graph_utm = graphs.nx_wgs_to_utm(graph_wgs) - # simplify - if simplify: - graph_utm = graphs.nx_simple_geoms(graph_utm) - graph_utm = graphs.nx_remove_filler_nodes(graph_utm) - graph_utm = graphs.nx_remove_dangling_nodes(graph_utm, despine=20, remove_disconnected=True) - graph_utm = graphs.nx_remove_filler_nodes(graph_utm) - graph_utm = graphs.nx_consolidate_nodes( - graph_utm, crawl=True, buffer_dist=10, min_node_group=3, cent_min_degree=4, cent_min_names=4 - ) - - if remove_parallel: - graph_utm = graphs.nx_split_opposing_geoms(graph_utm, buffer_dist=15) - graph_utm = graphs.nx_consolidate_nodes( - graph_utm, buffer_dist=15, crawl=False, min_node_degree=2, cent_min_degree=4, cent_min_names=4 - ) - graph_utm = graphs.nx_remove_filler_nodes(graph_utm) - - if iron_edges: - graph_utm = graphs.nx_iron_edge_ends(graph_utm) - - return graph_utm diff --git a/demos/general_util/plot_funcs.py b/demos/general_util/plot_funcs.py index f8d08dfa..4d142377 100644 --- a/demos/general_util/plot_funcs.py +++ b/demos/general_util/plot_funcs.py @@ -1,120 +1,73 @@ -from pathlib import Path +from __future__ import annotations -import matplotlib as mpl import matplotlib.pyplot as plt +import networkx as nx import numpy as np import numpy.typing as npt from matplotlib.colors import LinearSegmentedColormap +from shapely import geometry from sklearn.preprocessing import minmax_scale +from tqdm import tqdm template_cmap = LinearSegmentedColormap.from_list("reds", ["#FAFAFA", "#9a0007", "#ff6659", "#d32f2f"]) -def plt_setup(): - """Flush previous matplotlib invocations.""" - plt.close("all") - plt.cla() - plt.clf() - mpl.rcdefaults() # resets seaborn - mpl_rc_path = Path(Path.cwd() / "./matplotlib.rc") - mpl.rc_file(mpl_rc_path) - - -def _dynamic_view_extent(fig, ax, km_per_inch: float, centre: tuple): - bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) - width, height = bbox.width, bbox.height - width_m = width * km_per_inch * 1000 - height_m = height * km_per_inch * 1000 - x_left = centre[0] - width_m / 2 - x_right = centre[0] + width_m / 2 - y_bottom = centre[1] - height_m / 2 - y_top = centre[1] + height_m / 2 - - return x_left, x_right, y_bottom, y_top - - -def _view_idx(xs, ys, x_left, x_right, y_bottom, y_top): - select = xs > x_left - select = np.logical_and(select, xs < x_right) - select = np.logical_and(select, ys > y_bottom) - select = np.logical_and(select, ys < y_top) - select_idx = np.where(select)[0] - - return select_idx - - -def _prepare_v(vals): - # don't reshape distribution: emphasise larger values if necessary using exponential - # i.e. amplify existing distribution rather than using a reshaped normal or uniform distribution - # clip out outliers - vals = np.clip(vals, np.nanpercentile(vals, 0.1), np.nanpercentile(vals, 99.9)) - # scale colours to [0, 1] - vals = minmax_scale(vals, feature_range=(0, 1)) - return vals - - def plot_scatter( - fig, - ax, - xs, - ys, - vals=None, - bbox_extents: tuple[int, int, int, int] = None, - centre: tuple[int, int] = (532000, 183000), - km_per_inch=4, - s_min=0, - s_max=0.6, - c_exp=1, - s_exp=1, - cmap=None, - rasterized=True, - **kwargs, + ax: plt.Axes, + xs: npt.NDArray[np.float_], + ys: npt.NDArray[np.float_], + vals: npt.NDArray[np.float32], + bbox_extents: tuple[int, int, int, int] | tuple[float, float, float, float], + c_min: float = 0, + c_max: float = 1, + c_exp: float = 1, + s_min: float = 0, + s_max: float = 1, + s_exp: float = 1, + cmap_key: str = "Reds", + rasterized: bool = True, ): """ """ - if vals is not None and vals.ndim == 2: - raise ValueError("Please pass a single dimensional array") - if cmap is None: - cmap = template_cmap # get extents relative to centre and ax size - if bbox_extents: - print("Found bbox extents, ignoring centre") - y_bottom, x_left, y_top, x_rightpl = bbox_extents - else: - x_left, x_right, y_bottom, y_top = _dynamic_view_extent(fig, ax, km_per_inch, centre=centre) - select_idx = _view_idx(xs, ys, x_left, x_right, y_bottom, y_top) - if "c" in kwargs and isinstance(kwargs["c"], (list, tuple, np.ndarray)): - c = np.array(kwargs["c"]) - kwargs["c"] = c[select_idx] - elif "c" in kwargs and isinstance(kwargs["c"], str): - pass - elif vals is not None: - v = _prepare_v(vals) - # apply exponential - still [0, 1] - c = v**c_exp - kwargs["c"] = c[select_idx] - if "s" in kwargs and isinstance(kwargs["c"], (list, tuple, np.ndarray)): - s = np.array(kwargs["c"]) - kwargs["s"] = s[select_idx] - elif vals is not None: - v = _prepare_v(vals) - s = v**s_exp - # rescale s to [s_min, s_max] - s = minmax_scale(s, feature_range=(s_min, s_max)) - kwargs["s"] = s[select_idx] + min_x, min_y, max_x, max_y = bbox_extents + # filter + select = xs > min_x + select = np.logical_and(select, xs < max_x) + select = np.logical_and(select, ys > min_y) + select = np.logical_and(select, ys < max_y) + select_idx = np.where(select)[0] + # remove any extreme outliers + v = np.clip(vals, np.nanpercentile(vals, 0.1), np.nanpercentile(vals, 99.9)) + # shape if wanted + c = v**c_exp + c: npt.NDArray[np.float_] = minmax_scale(c, feature_range=(c_min, c_max)) + s = v**s_exp + s: npt.NDArray[np.float_] = minmax_scale(s, feature_range=(s_min, s_max)) + # plot im = ax.scatter( - xs[select_idx], ys[select_idx], linewidths=0, edgecolors="none", cmap=cmap, rasterized=rasterized, **kwargs + xs[select_idx], + ys[select_idx], + c=c[select_idx], + s=s[select_idx], + linewidths=0, + edgecolors="none", + cmap=plt.get_cmap(cmap_key), + rasterized=rasterized, ) - ax.set_xlim(left=x_left, right=x_right) - ax.set_ylim(bottom=y_bottom, top=y_top) + # limits + ax.set_xlim(left=min_x, right=max_x) + ax.set_ylim(bottom=min_y, top=max_y) ax.set_xticks([]) ax.set_yticks([]) ax.set_aspect(1) + ax.set_facecolor("white") + return im def plot_heatmap( heatmap_ax, - heatmap: npt.NDArray[np.float32] = None, + heatmap: npt.NDArray[np.float32] | None = None, row_labels: list = None, col_labels: list = None, set_row_labels: bool = True, @@ -184,3 +137,40 @@ def plot_heatmap( fontsize=grid_fontsize, ) return im + + +def plot_nx_edges( + ax: plt.Axes, + nx_multigraph: nx.MultiGraph, + edge_metrics_key: str, + bbox_extents: tuple[int, int, int, int] | tuple[float, float, float, float], + colour: str = "#ef1a33", + rasterized: bool = True, +): + """ """ + min_x, min_y, max_x, max_y = bbox_extents # type: ignore + # extract data for shaping + edge_vals: list[str] = [] + edge_geoms: list[geometry.LineString] = [] + for _, _, edge_data in tqdm(nx_multigraph.edges(data=True)): # type: ignore + edge_vals.append(edge_data[edge_metrics_key]) # type: ignore + edge_geoms.append(edge_data["geom"]) # type: ignore + edge_vals_arr: npt.NDArray[np.float_] = np.array(edge_vals) + edge_vals_arr = np.clip(edge_vals_arr, np.nanpercentile(edge_vals_arr, 0.1), np.nanpercentile(edge_vals_arr, 99.9)) + # plot using geoms + n_edges = edge_vals_arr.shape[0] + for idx in tqdm(range(n_edges)): + xs = np.array(edge_geoms[idx].coords.xy[0]) + ys = np.array(edge_geoms[idx].coords.xy[1]) + if np.any(xs < min_x) or np.any(xs > max_x): + continue + if np.any(ys < min_y) or np.any(ys > max_y): + continue + # normalise val + edge_val = edge_vals_arr[idx] + norm_val = (edge_val - edge_vals_arr.min()) / (edge_vals_arr.max() - edge_vals_arr.min()) + val_shape = norm_val * 0.95 + 0.05 + ax.plot(xs, ys, linewidth=val_shape, color=colour, rasterized=rasterized) + ax.axis("off") + plt.xlim(min_x, max_x) + plt.ylim(min_y, max_y) diff --git a/docs/plots/plots.py b/docs/plots/plots.py index dc51fa4c..b1d7c4af 100644 --- a/docs/plots/plots.py +++ b/docs/plots/plots.py @@ -8,7 +8,7 @@ from shapely import geometry from cityseer.metrics import layers, networks # pylint: disable=import-error -from cityseer.tools import graphs, mock, osm, plot # pylint: disable=import-error +from cityseer.tools import graphs, io, mock, plot # pylint: disable=import-error PLOT_RC_PATH = pathlib.Path(__file__).parent / "matplotlibrc" print(f"matplotlibrc path: {PLOT_RC_PATH}") @@ -100,9 +100,9 @@ # graph cleanup examples lng, lat = -0.13396079424572427, 51.51371088849723 buffer = 1250 -poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = osm.buffered_point_poly(lng, lat, buffer) -graph_raw = osm.osm_graph_from_poly_wgs(poly_wgs, simplify=False) -graph_utm = osm.osm_graph_from_poly_wgs(poly_wgs, simplify=True, remove_parallel=True, iron_edges=True) +poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = io.buffered_point_poly(lng, lat, buffer) +graph_raw = io.osm_graph_from_poly_wgs(poly_wgs, simplify=False) +graph_utm = io.osm_graph_from_poly_wgs(poly_wgs, simplify=True, remove_parallel=True, iron_edges=True) # plot buffer easting, northing = utm.from_latlon(lat, lng)[:2] buff = geometry.Point(easting, northing).buffer(750) @@ -340,12 +340,12 @@ def simple_plot(_G, _path, plot_geoms=True): multi_di_graph_simpl = ox.simplify_graph(multi_di_graph_utm) multi_di_graph_cons = ox.consolidate_intersections(multi_di_graph_simpl, tolerance=10, dead_ends=True) # let's use the same plotting function for both scenarios to aid visual comparisons -multi_graph_cons = graphs.nx_from_osm_nx(multi_di_graph_cons, tolerance=50) +multi_graph_cons = io.nx_from_osm_nx(multi_di_graph_cons, tolerance=50) simple_plot(multi_graph_cons, f"{IMAGES_PATH}/osmnx_simplification.{FORMAT}") # WORKFLOW 2: Using cityseer to manually clean an OSMnx graph # =========================================================== -G_raw = graphs.nx_from_osm_nx(multi_di_graph_raw) +G_raw = io.nx_from_osm_nx(multi_di_graph_raw) G = graphs.nx_wgs_to_utm(G_raw) G = graphs.nx_simple_geoms(G) G = graphs.nx_remove_filler_nodes(G) @@ -362,6 +362,6 @@ def simple_plot(_G, _path, plot_geoms=True): # WORKFLOW 3: Using cityseer to download and automatically simplify the graph # =========================================================================== -poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = osm.buffered_point_poly(lng, lat, buffer_dist) -G_utm = osm.osm_graph_from_poly_wgs(poly_wgs, simplify=True, remove_parallel=True, iron_edges=True) +poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = io.buffered_point_poly(lng, lat, buffer_dist) +G_utm = io.osm_graph_from_poly_wgs(poly_wgs, simplify=True, remove_parallel=True, iron_edges=True) simple_plot(G_utm, f"{IMAGES_PATH}/cityseer_only_simplification.{FORMAT}") diff --git a/docs/src/layouts/PageLayout.astro b/docs/src/layouts/PageLayout.astro index 4fb584d7..303f1bc0 100644 --- a/docs/src/layouts/PageLayout.astro +++ b/docs/src/layouts/PageLayout.astro @@ -13,10 +13,11 @@ const navPaths = [ '/intro/', '/guide/', '/examples/', + '/metrics/observe/', '/metrics/networks/', '/metrics/layers/', + '/tools/io/', '/tools/graphs/', - '/tools/osm/', '/tools/plot/', '/tools/mock/', '/structures/', diff --git a/docs/src/pages/examples.md b/docs/src/pages/examples.md index 977c12a7..82a5d187 100644 --- a/docs/src/pages/examples.md +++ b/docs/src/pages/examples.md @@ -18,7 +18,7 @@ The `Getting Started` guide from the [intro](/intro/). Github: graph_cleaning.ipynb -## Importing OSM data with OSMnx +## Importing OSM data An example of how to import OSM data as discussed in [OSM and NetworkX](/guide#osm-and-networkx). @@ -26,9 +26,13 @@ An example of how to import OSM data as discussed in [OSM and NetworkX](/guide#o ## Centralities for Inner London -An example workflow computing network centralities for London. +An example workflow computing network centralities for London using Ordnance Survey Open Roads data: -Github: london_centrality.ipynb +Github: centrality_os_open.ipynb + +An example workflow computing network centralities for London using OSM data: + +Github: centrality_osm.ipynb ## Accessibility to Pubs for Inner London diff --git a/docs/src/pages/guide.md b/docs/src/pages/guide.md index 6c46e441..530a495a 100644 --- a/docs/src/pages/guide.md +++ b/docs/src/pages/guide.md @@ -50,20 +50,20 @@ This example will make use of OSM data downloaded from the [OSM API](https://wik from shapely import geometry import utm -from cityseer.tools import graphs, plot, osm +from cityseer.tools import graphs, plot, io # Let's download data within a 1,250m buffer around London Soho: lng, lat = -0.13396079424572427, 51.51371088849723 buffer = 1250 # creates a WGS shapely polygon -poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = osm.buffered_point_poly( +poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = io.buffered_point_poly( lng, lat, buffer ) # use a WGS shapely polygon to download information from OSM # this version will not simplify -G_raw = osm.osm_graph_from_poly_wgs(poly_wgs, simplify=False) +G_raw = io.osm_graph_from_poly_wgs(poly_wgs, simplify=False) # whereas this version does simplify -G_utm = osm.osm_graph_from_poly_wgs( +G_utm = io.osm_graph_from_poly_wgs( poly_wgs, simplify=True, remove_parallel=True, iron_edges=True ) @@ -94,7 +94,7 @@ _The pre-consolidation OSM street network for Soho, London. © OpenStreetMap con ![The automatically cleaned graph from OSM](/images/graph_cleaning_1b.png) _The automatically cleaned OSM street network for Soho, London. © OpenStreetMap contributors._ -The automated graph cleaning provided by [osm_graph_from_poly_wgs](/tools/osm/#osm-graph-from-poly-wgs) may give satisfactory results depending on the intended end-use. See the steps following beneath for an example of how to manually clean the graph where additional control is preferred. +The automated graph cleaning provided by [osm_graph_from_poly_wgs](/tools/io/#osm-graph-from-poly-wgs) may give satisfactory results depending on the intended end-use. See the steps following beneath for an example of how to manually clean the graph where additional control is preferred. ### Deducing the network topology @@ -201,8 +201,8 @@ The above recipe should be enough to get you started, but there are innumerable The following points may be helpful when using `OSMnx` and `cityseer` together: -- `OSMnx` prepared graphs can be converted to `cityseer` compatible graphs by using the [`tools.graphs.nx_from_osm_nx`](/tools/graphs#nx-from-osm-nx) method. In doing so, keep the following in mind: - - `OSMnx` uses `networkX` `multiDiGraph` graph structures that use directional edges. As such, it can be used for understanding vehicular routing, i.e. where one-way routes can have a major impact on the available shortest-routes. `cityseer` is only concerned with pedestrian networks and therefore uses `networkX` `MultiGraphs` on the premise that pedestrian networks are not ordinarily directional. When using the [`tools.graphs.nx_from_osm_nx`](/tools/graphs#nx-from-osm-nx) method, be cognisant that all directional information will be discarded. +- `OSMnx` prepared graphs can be converted to `cityseer` compatible graphs by using the [`tools.io.nx_from_osm_nx`](/tools/graphs#nx-from-osm-nx) method. In doing so, keep the following in mind: + - `OSMnx` uses `networkX` `multiDiGraph` graph structures that use directional edges. As such, it can be used for understanding vehicular routing, i.e. where one-way routes can have a major impact on the available shortest-routes. `cityseer` is only concerned with pedestrian networks and therefore uses `networkX` `MultiGraphs` on the premise that pedestrian networks are not ordinarily directional. When using the [`tools.io.nx_from_osm_nx`](/tools/graphs#nx-from-osm-nx) method, be cognisant that all directional information will be discarded. - `cityseer` graph simplification and consolidation workflows will give different results to those employed in `OSMnx`. If you're using `OSMnx` to ingest networks from `OSM` but wish to simplify and consolidate the network as part of a `cityseer` workflow, set the `OSMnx` `simplify` argument to `False` so that the network is not automatically simplified. - `cityseer` uses internal validation workflows to check that the geometries associated with an edge remain connected to the coordinates of the nodes on either side. If performing graph manipulation outside of `cityseer` before conversion, the conversion function may complain of disconnected geometries. In these cases, you may need to relax the tolerance parameter used for error checking upon conversion to a `cityseer` `MultiGraph`, in which case geometries disconnected from their end-nodes (within the tolerance parameter) will be "snapped" to meet their endpoints as part of the conversion process. @@ -213,7 +213,7 @@ import osmnx as ox from shapely import geometry import utm -from cityseer.tools import graphs, plot, osm +from cityseer.tools import graphs, plot, io # centrepoint lng, lat = -0.13396079424572427, 51.51371088849723 @@ -251,12 +251,12 @@ multi_di_graph_utm = ox.project_graph(multi_di_graph_raw) multi_di_graph_simpl = ox.simplify_graph(multi_di_graph_utm) multi_di_graph_cons = ox.consolidate_intersections(multi_di_graph_simpl, tolerance=10, dead_ends=True) # let's use the same plotting function for both scenarios to aid visual comparisons -multi_graph_cons = graphs.nx_from_osm_nx(multi_di_graph_cons, tolerance=50) +multi_graph_cons = io.nx_from_osm_nx(multi_di_graph_cons, tolerance=50) simple_plot(multi_graph_cons) # WORKFLOW 2: Using cityseer to manually clean an OSMnx graph # =========================================================== -G_raw = graphs.nx_from_osm_nx(multi_di_graph_raw) +G_raw = io.nx_from_osm_nx(multi_di_graph_raw) G = graphs.nx_wgs_to_utm(G_raw) G = graphs.nx_simple_geoms(G) G = graphs.nx_remove_filler_nodes(G) @@ -275,8 +275,8 @@ simple_plot(G4) # WORKFLOW 3: Using cityseer to download and automatically simplify the graph # =========================================================================== -poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = osm.buffered_point_poly(lng, lat, buffer_dist) -G_utm = osm.osm_graph_from_poly_wgs(poly_wgs, simplify=True, remove_parallel=True, iron_edges=True) +poly_wgs, _poly_utm, _utm_zone_number, _utm_zone_letter = io.buffered_point_poly(lng, lat, buffer_dist) +G_utm = io.osm_graph_from_poly_wgs(poly_wgs, simplify=True, remove_parallel=True, iron_edges=True) simple_plot(G_utm) ``` diff --git a/pdm.lock b/pdm.lock index 221efed0..baa981e8 100644 --- a/pdm.lock +++ b/pdm.lock @@ -822,7 +822,7 @@ dependencies = [ [[package]] name = "pandas-stubs" -version = "1.4.3.220724" +version = "1.4.3.220801" requires_python = ">=3.8,<3.11" summary = "Type annotations for pandas" dependencies = [ @@ -984,7 +984,7 @@ dependencies = [ [[package]] name = "pyright" -version = "1.1.264" +version = "1.1.265" requires_python = ">=3.7" summary = "Command line wrapper for pyright" dependencies = [ @@ -1256,7 +1256,7 @@ summary = "Typing stubs for pytz" [[package]] name = "types-requests" -version = "2.28.5" +version = "2.28.7" summary = "Typing stubs for requests" dependencies = [ "types-urllib3<1.27", @@ -2081,9 +2081,9 @@ content_hash = "sha256:0fa61d545097a60d058498b3ce980b0e4fa9d3bd5f6f6d2412cfe3e76 {url = "https://files.pythonhosted.org/packages/ed/7d/25f52988bd6949319946ff99a75b547f7bf3f20aff8b2b84fda047bdcd04/pandas-1.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:755679c49460bd0d2f837ab99f0a26948e68fa0718b7e42afbabd074d945bf84"}, {url = "https://files.pythonhosted.org/packages/f4/00/2de395c769335956b8650f990ef2a15e860be83b544c408ff95713446329/pandas-1.4.3.tar.gz", hash = "sha256:2ff7788468e75917574f080cd4681b27e1a7bf36461fe968b49a87b5a54d007c"}, ] -"pandas-stubs 1.4.3.220724" = [ - {url = "https://files.pythonhosted.org/packages/79/e2/d8ed49cce3b69902a212f4d35079c578475a6f45b6ccd52f560d5dd1f27e/pandas_stubs-1.4.3.220724-py3-none-any.whl", hash = "sha256:8e6bda51bfa8b3d90ef9ae8d9504e7fbe59e884ccdd9810e6e59a83ef4b35b03"}, - {url = "https://files.pythonhosted.org/packages/a6/37/516b6efbdd36986b6bbed54e05b33d2e2cff3ecc1d3cbbce45612b633a25/pandas-stubs-1.4.3.220724.tar.gz", hash = "sha256:5332d239d4b8d9d37927e3da207d637d1bde6aed490a0ef80de0efbd6af8e023"}, +"pandas-stubs 1.4.3.220801" = [ + {url = "https://files.pythonhosted.org/packages/30/8d/174097d3efe7bcb547afb687a30efb09adcf1f432c4b52e6e171b7db7306/pandas_stubs-1.4.3.220801-py3-none-any.whl", hash = "sha256:abe78bfebab257cbdf766b4974c92a592a869f5de2393a5077acab239ec30648"}, + {url = "https://files.pythonhosted.org/packages/c0/c2/9e0b6568a3dd2b43915cd665bf8af665af119239aff54b343cbdd145288f/pandas-stubs-1.4.3.220801.tar.gz", hash = "sha256:16e15308ad4dab35f485c3cccdcec63be92e43395de6809435616d353e826d00"}, ] "pandocfilters 1.5.0" = [ {url = "https://files.pythonhosted.org/packages/5e/a8/878258cffd53202a6cc1903c226cf09e58ae3df6b09f8ddfa98033286637/pandocfilters-1.5.0-py2.py3-none-any.whl", hash = "sha256:33aae3f25fd1a026079f5d27bdd52496f0e0803b3469282162bafdcbdf6ef14f"}, @@ -2304,21 +2304,23 @@ content_hash = "sha256:0fa61d545097a60d058498b3ce980b0e4fa9d3bd5f6f6d2412cfe3e76 {url = "https://files.pythonhosted.org/packages/50/e6/8f52ff23c35e95c74bdf181793b5010a65dac681fb62e4fcac0ac46b814b/pyproj-3.3.1-cp38-cp38-win32.whl", hash = "sha256:c99f7b5757a28040a2dd4a28c9805fdf13eef79a796f4a566ab5cb362d10630d"}, {url = "https://files.pythonhosted.org/packages/54/7e/531d8081c4a7ae372e57cc8a88f811d48ce7c7c215ccc9d5364576b5bead/pyproj-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b59c08aea13ee428cf8a919212d55c036cc94784805ed77c8f31a4d1f541058c"}, {url = "https://files.pythonhosted.org/packages/58/40/71c7bc5b4dff3d7c87fe9f711664e2fe18705343a9d038c7e9bfd235aeea/pyproj-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:56b0f9ee2c5b2520b18db30a393a7b86130cf527ddbb8c96e7f3c837474a9d79"}, + {url = "https://files.pythonhosted.org/packages/5d/1b/0efdf2864a48814cc0c36829619091e28ffd16155e07a4530405f6ea5bd7/pyproj-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f1032e5dfb50eae06382bcc7b9011b994f7104d932fe91bd83a722275e30e8ce"}, {url = "https://files.pythonhosted.org/packages/5f/ec/14762a490dc62ac94de8b1e82a503a18246531052fa431047d2d1f2d5357/pyproj-3.3.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:797ad5655d484feac14b0fbb4a4efeaac0cf780a223046e2465494c767fd1c3b"}, {url = "https://files.pythonhosted.org/packages/6c/b8/cb63be33635d2b5a785cf16c1cf2e4b43fa74b5c7937ece4b23d41f78c7c/pyproj-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f92d8f6514516124abb714dce912b20867831162cfff9fae2678ef07b6fcf0f"}, {url = "https://files.pythonhosted.org/packages/72/a8/a9f25be98ad990de2e83c91845c4424d6cb4e5c0c5ec8717f58f6685773b/pyproj-3.3.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:140fa649fedd04f680a39f8ad339799a55cb1c49f6a84e1b32b97e49646647aa"}, {url = "https://files.pythonhosted.org/packages/92/c6/70739c94ef82a51338611b4794e70c3814a4b8d3ca21ba3b6e695e5fa521/pyproj-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ca5f32b56210429b367ca4f9a57ffe67975c487af82e179a24370879a3daf68"}, {url = "https://files.pythonhosted.org/packages/95/2a/6c8d4a3eb338140ce679309f9573c5b0701921dea430920681db083fc126/pyproj-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67025e37598a6bbed2c9c6c9e4c911f6dd39315d3e1148ead935a5c4d64309d5"}, {url = "https://files.pythonhosted.org/packages/b1/9b/b518dfe6aaba1d06b8cdb2a686f98eafa14e16ace2f7f3e199bdfd47389c/pyproj-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:5dac03d4338a4c8bd0f69144c527474f517b4cbd7d2d8c532cd8937799723248"}, + {url = "https://files.pythonhosted.org/packages/b7/13/5a2215b2a6ada389d0f7f984a743bed925c395dcc81d185675ec9326a3ac/pyproj-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07c9d8d7ec009bbac09e233cfc725601586fe06880e5538a3a44eaf560ba3a62"}, {url = "https://files.pythonhosted.org/packages/c3/fd/4694f009d57810623f829ff5a235b748ee17b7eb462bee0434e82cfae4f8/pyproj-3.3.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aed1a3c0cd4182425f91b48d5db39f459bc2fe0d88017ead6425a1bc85faee33"}, {url = "https://files.pythonhosted.org/packages/d3/a3/f4f5690615e1a2b50d0cf6373a08f7620430b71f3ede92031c66a42e3bbc/pyproj-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cc4771403db54494e1e55bca8e6d33cde322f8cf0ed39f1557ff109c66d2cd1"}, {url = "https://files.pythonhosted.org/packages/db/d6/333618912416f3033248121e447968e5edf4f8606658b8b8b4fe0ef6eb1a/pyproj-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fef9c1e339f25c57f6ae0558b5ab1bbdf7994529a30d8d7504fc6302ea51c03"}, {url = "https://files.pythonhosted.org/packages/e3/4d/348402c2fb0d8a8e85a88b8babc6f4efaae9692b7524aedce5fddbef3baf/pyproj-3.3.1.tar.gz", hash = "sha256:b3d8e14d91cc95fb3dbc03a9d0588ac58326803eefa5bbb0978d109de3304fbe"}, {url = "https://files.pythonhosted.org/packages/e8/1e/8b4613f2aba4fbf94640263b3e8b4a2778367648b3ed3b374267ec3aaa75/pyproj-3.3.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45487942c19c5a8b09c91964ea3201f4e094518e34743cae373889a36e3d9260"}, ] -"pyright 1.1.264" = [ - {url = "https://files.pythonhosted.org/packages/20/53/af88b70b5bfd5d52a7c29920512018e99b287afe799346c232fb8e72ac52/pyright-1.1.264-py3-none-any.whl", hash = "sha256:845c0bfa77695e81b19980fe4777a771016cbc6a18b821aa492a516a2df2fae1"}, - {url = "https://files.pythonhosted.org/packages/96/ba/c16be6e0e832f9dca5c844c277b8ae4f492bac3bf2abf7bc4da1bcdc95ba/pyright-1.1.264.tar.gz", hash = "sha256:93ffceb70c0f817d89122bf133583a41842c466d941aa4e92a4bb6a06bd3cd9a"}, +"pyright 1.1.265" = [ + {url = "https://files.pythonhosted.org/packages/04/f8/6e304a656a027c7c23dee6329d2903c589113eefbbcca2076c2cce100919/pyright-1.1.265.tar.gz", hash = "sha256:0a98fd55ba8b9c16145c16b79c524fdb1daf55ed6dc094c8a7a2dd540af00b67"}, + {url = "https://files.pythonhosted.org/packages/80/73/2a8e7ec731e0772eab37ca796e5a1d17c36815d0dd432aa0b9a318930a9e/pyright-1.1.265-py3-none-any.whl", hash = "sha256:a504d67d559dc90bf7209df090d79392fee47f308b36066a467b98d2a4aa471d"}, ] "pyrsistent 0.18.1" = [ {url = "https://files.pythonhosted.org/packages/15/fa/64ed4c29d36df26906f03a1fb360056e3cbc063b00446f3663252bdd175a/pyrsistent-0.18.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f87cc2863ef33c709e237d4b5f4502a62a00fab450c9e020892e8e2ede5847f5"}, @@ -2651,9 +2653,9 @@ content_hash = "sha256:0fa61d545097a60d058498b3ce980b0e4fa9d3bd5f6f6d2412cfe3e76 {url = "https://files.pythonhosted.org/packages/28/f4/a6a3f28aed2d9de336c032314713981ce6f8786e37c431323dd74a218ee1/types_pytz-2022.1.2-py3-none-any.whl", hash = "sha256:8aa9fd2af9dee5f5bd7221c6804c9addeafa7ebc0008f544d4ace02b066818a4"}, {url = "https://files.pythonhosted.org/packages/60/1e/0619a911fed3fe1a1f050d4e433b67f8b74a74c61d0b2ba50c847761614a/types-pytz-2022.1.2.tar.gz", hash = "sha256:1a8b25c225c5e6bd8468aa9eb45ddd3b337f6716d4072ad0aa4ef1e41478eebc"}, ] -"types-requests 2.28.5" = [ - {url = "https://files.pythonhosted.org/packages/b4/b0/f43b6c00c7b3d586419816ff8b9ee7bfce8ad9ec16e3b372c3562265476e/types-requests-2.28.5.tar.gz", hash = "sha256:ac618bfefcb3742eaf97c961e13e9e5a226e545eda4a3dbe293b898d40933ad1"}, - {url = "https://files.pythonhosted.org/packages/d6/4b/aecd50a273e7b8f5f3de2d240bb68dbc9e655a0447b6cb5ba1fddeb64fce/types_requests-2.28.5-py3-none-any.whl", hash = "sha256:98ab647ae88b5e2c41d6d20cfcb5117da1bea561110000b6fdeeea07b3e89877"}, +"types-requests 2.28.7" = [ + {url = "https://files.pythonhosted.org/packages/d0/3a/c0b695df523073e7635eba23ba34e44c9a0e49980cecd2b1cdbbef1705a8/types-requests-2.28.7.tar.gz", hash = "sha256:36385618d4bd2ee3211d4d2e78b44f067ceb5984865c0f253f3c9ecb964526cf"}, + {url = "https://files.pythonhosted.org/packages/ee/1f/fad02f338584c6be3ba3da598f4e6ee5760bbee809e4ebdb8cf3c0613f5f/types_requests-2.28.7-py3-none-any.whl", hash = "sha256:38015d310d13cf7d4d712d2507178349e13fd5dab85259dab7d9a9884c2c9c2a"}, ] "types-urllib3 1.26.16" = [ {url = "https://files.pythonhosted.org/packages/04/9f/65ce26fb8b191c91ec7afe3804da305c02331111d8abec7bf706a35b68a5/types_urllib3-1.26.16-py3-none-any.whl", hash = "sha256:20588c285e5ca336d908d2705994830a83cfb6bda40fc356bbafaf430a262013"}, diff --git a/pyproject.toml b/pyproject.toml index 22eb40aa..a38a2689 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -249,10 +249,11 @@ outro_template = """ """ module_map = [ { module = "cityseer.structures", py = "cityseer/structures.py", astro = "docs/src/pages/structures.astro" }, - { module = "cityseer.metrics.layers", py = "cityseer/metrics/layers.py", astro = "docs/src/pages/metrics/layers.astro" }, + { module = "cityseer.metrics.observe", py = "cityseer/metrics/observe.py", astro = "docs/src/pages/metrics/observe.astro" }, { module = "cityseer.metrics.networks", py = "cityseer/metrics/networks.py", astro = "docs/src/pages/metrics/networks.astro" }, + { module = "cityseer.metrics.layers", py = "cityseer/metrics/layers.py", astro = "docs/src/pages/metrics/layers.astro" }, { module = "cityseer.tools.graphs", py = "cityseer/tools/graphs.py", astro = "docs/src/pages/tools/graphs.astro" }, - { module = "cityseer.tools.osm", py = "cityseer/tools/osm.py", astro = "docs/src/pages/tools/osm.astro" }, + { module = "cityseer.tools.io", py = "cityseer/tools/io.py", astro = "docs/src/pages/tools/io.astro" }, { module = "cityseer.tools.plot", py = "cityseer/tools/plot.py", astro = "docs/src/pages/tools/plot.astro" }, { module = "cityseer.tools.mock", py = "cityseer/tools/mock.py", astro = "docs/src/pages/tools/mock.astro" }, ] diff --git a/tests/tools/test_graphs.py b/tests/tools/test_graphs.py index 29316580..3f2b31d4 100644 --- a/tests/tools/test_graphs.py +++ b/tests/tools/test_graphs.py @@ -1184,8 +1184,3 @@ def test_nx_from_network_structure(primal_graph): network_structure, nx_multigraph=corrupt_primal_graph, ) - - -def test_nx_from_osm_nx(): - # TODO: not yet implemented. - pass