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