09 · Accessibility and places

Reachability, and the places you can reach: isochrones, ego subgraphs, points of interest snapped onto the network, and clustering that measures distance along streets rather than across them.

Notebook 17 answers the same fifteen-minute question about the same place through City2Graph instead, and where the two disagree the disagreement is the finding.

09 · Accessibility: what you can reach, and what is there

From here, in fifteen minutes, where can I get and what is there when I arrive? Notebook 17 answers that through city2graph (add_metapaths_by_weight, create_isochrone). This one uses the OSMnx operators: osmnx_isochrones, osmnx_ego_subgraph, osmnx_network_dbscan, the six feature operators and osmnx_snap_pois. Section 6 puts the two side by side on the same place, speed and threshold.

Four things, in the order they have to be settled: the cost, the polygon (a reachable node set is not an area, and that conversion holds most of the error here), the places, and network distance against distance in the plane.

Network: almost nothing. Every graph and POI set below comes from the cache notebooks 17, 06 and 07 warmed. Section 4 makes one small Overpass request (amenity=restaurant over 600 m) and falls back to the cached set if the server refuses.

0 · Setup

import pathlib
import sys


def find_repo():
    """Locate the SciGraphs repository, without relying on the cwd.

    In a normal Jupyter the kernel starts per notebook with the cwd set to its
    folder. Not here: the kernel lives inside Blender and existed before you
    opened anything, so the cwd is wherever Blender was launched from. What the
    extension does know is which folder it is serving to JupyterLab.
    """
    candidates = []
    try:
        import bpy
        prefs = bpy.context.preferences.addons[
            "bl_ext.user_default.jupyter_blender"].preferences
        candidates.append(pathlib.Path(bpy.path.abspath(prefs.notebook_dir)))
    except Exception:
        pass
    candidates.append(pathlib.Path.cwd())

    for base in candidates:
        for directory in (base, *base.parents):
            if (directory / "SciGraphs" / "api" / "graphs.py").is_file():
                return directory

    raise RuntimeError(
        "Cannot find the SciGraphs repository. Point at it by hand:\n"
        "    sys.path.insert(0, '/path/to/SciGraphs-1/notebooks/tools')")


sys.path.insert(0, str(find_repo() / "notebooks" / "tools"))

import importlib
import math
import time

import bpy
import numpy as np
import pandas as pd

# `nb` must import first: it puts this repository on sys.path, so the
# `SciGraphs` import below reads this tree, not the copy Blender installed.
import nb
from nb import check

from SciGraphs import api as sg

# Before a single byte moves: the add-on sets `use_cache` but not
# `cache_folder`, so the relative default follows Blender's launch directory
# and a run from two places keeps two caches, neither hit twice (notebook 06).
ox = nb.osmnx()

import city2graph as c2g
import geopandas as gpd
import networkx as nx
import pyproj
from shapely.geometry import LineString, Polygon
from shapely.ops import transform, unary_union

print(f"osmnx  {ox.__version__}")
print(f"cache  {nb.rel(ox.settings.cache_folder)}")
osmnx  2.0.7
cache  notebooks/data/cache/osmnx

The study area, and the modules that go with it

Ciutat Vella, València, at 600 m: the same center, radius and walk filter notebook 17 uses, because section 6 compares the two answers. 4.8 km/h is the pedestrian speed the accessibility literature and notebook 17 both assume; 900 s is the fifteen minutes.

The operators run inside the copy of SciGraphs that Blender registered, and the graphs they hand back live in a module-level dict inside that copy. import SciGraphs reaches a different module object with an empty cache, so every lookup would come back None (notebook 11 works the trap through). Hence the imports below.

CENTER = (39.4699, -0.3763)      # Ciutat Vella, València
RADIUS_M = 600
SPEED_KPH = 4.8
SPEED_MS = SPEED_KPH * 1000.0 / 3600.0
THRESHOLD_S = 900
METRIC_CRS = "EPSG:25830"        # ETRS89 / UTM 30N
RENDERS = "09_osmnx_accessibility"

INSTALLED = next(k for k in bpy.context.preferences.addons.keys()
                 if k.rsplit(".", 1)[-1].lower() == "scigraphs")
graph_cache = importlib.import_module(INSTALLED + ".core.osmnx.graph_cache")
# `scigraphs_core` is a wheel: one copy in site-packages, shared by the notebook
# and the operators, so the installed/working-tree split does not apply to it.
mesh_bridge = importlib.import_module("scigraphs_core.osmnx.mesh_bridge")
sg_importer = importlib.import_module(INSTALLED + ".core.data_io.importer")
sg_geometry = importlib.import_module(INSTALLED + ".core.mesh.geometry")
sg_geo_mesh = importlib.import_module(INSTALLED + ".core.mesh.geo_mesh")
sg_access = importlib.import_module("scigraphs_core.osmnx.accessibility")
sg_metadata = importlib.import_module("scigraphs_core.osmnx.metadata")

to_utm = pyproj.Transformer.from_crs("EPSG:4326", METRIC_CRS, always_xy=True)
GEOD = pyproj.Geod(ellps="WGS84")

props = bpy.context.scene.scigraphs
sg.graphs.clear_scene(keep_anchor=False)
print("add-on package:", INSTALLED)
add-on package: bl_ext.user_default.scigraphs

Five helpers

materialize(G, name) builds a Blender object from a NetworkX graph with no download, so the object, the operators and section 6’s comparison all look at the identical cached MultiDiGraph. The isochrone and ego highlights below are induced subgraphs materialized the same way, so they carry real street geometry. It needs a context override because create_osmnx_graph_object links the new object through bpy.context.collection (SciGraphs/core/mesh/geometry.py:1740), which is None under blender -b, and that is how verify_notebooks.py runs this file.

node_attribute(obj, values, name) puts a per-node quantity on the mesh. An OSMnx mesh has many more vertices than nodes (retain_geometry stores every shape point of every centerline) and mesh_bridge.transfer_node_attribute_to_mesh leaves all of them at 0.0, a value no intersection has, at the bottom of the color ramp. Here each shape vertex takes the mean of the two intersections its street runs between. fill='endpoint' is the variant for a quantity that does not vary along a street, a cluster label; section 5 explains it.

plain(obj) strips a previous coloring. sg.render.material() will not replace an existing *_SciGraphsColor material and rewires whichever color layer it finds, both deliberately: a graph colored and then rendered has to keep its colormap. This notebook draws the same network under four attributes, the one sequence where that is wrong.

luminance/overlay are notebook 04’s arrangement; section 2 explains why a reachable set needs it.

NODE_FRACTION = 0.16      # the context's node radius; section "Rendering"
CONTEXT = (0.085, 0.080, 0.100)
FAT = 4.0
LAYERS = []               # every graph object, for hide bookkeeping


def materialize(G, name, scale=0.001):
    """A Blender object for a NetworkX graph, with no download."""
    data, geoms = sg_importer.osmnx_to_graph_data(G, retain_geometry=True)
    if data is None:
        return None
    with bpy.context.temp_override(collection=bpy.context.scene.collection,
                                   scene=bpy.context.scene,
                                   view_layer=bpy.context.view_layer):
        obj = sg_geometry.create_osmnx_graph_object(data, geoms, scale=scale,
                                                    retain_geometry=True)
    if obj is None:
        return None
    obj.name = name
    graph_cache.store_osmnx_graph(obj, G)
    obj["osmnx_scale"] = scale
    LAYERS.append(obj)
    return obj


def node_attribute(obj, values, name, fill="mean", default=None):
    """Write a per-node quantity onto an OSMnx mesh, filling the shape points.

    `values` maps node id -> number. `fill='mean'` gives a shape vertex the
    mean of the two intersections its street connects; `fill='endpoint'` gives
    it the shared value when both agree and `default` when they do not.
    """
    mesh = obj.data
    ids = str(obj.get("nodes_data", "")).split(",")
    array = np.zeros(len(mesh.vertices), dtype=np.float64)
    known = np.zeros(len(mesh.vertices), dtype=bool)
    touched = np.zeros(len(mesh.vertices), dtype=bool)

    def key(text):
        return int(text) if text.lstrip("-").isdigit() else text

    for index, node_id in enumerate(ids):
        if index >= len(array):
            break
        value = values.get(key(node_id))
        if value is not None:
            array[index] = float(value)
            known[index] = touched[index] = True

    edge_verts = np.empty(len(mesh.edges) * 2, dtype=np.int32)
    mesh.edges.foreach_get("vertices", edge_verts)
    edge_verts = edge_verts.reshape(-1, 2)
    for (u, v), indices in mesh_bridge.build_edge_mapping(obj).items():
        a, b = values.get(key(u)), values.get(key(v))
        if a is None or b is None:
            continue
        if fill == "mean":
            filler = 0.5 * (float(a) + float(b))
        else:
            filler = float(a) if float(a) == float(b) else float(default)
        for edge_index in indices:
            for vertex in edge_verts[edge_index]:
                if not touched[vertex]:
                    array[vertex] = filler
                    touched[vertex] = True

    real = array[known]
    if len(real) and not touched.all():
        array[~touched] = float(real.mean()) if fill == "mean" else float(default)

    if name in mesh.attributes:
        mesh.attributes.remove(mesh.attributes[name])
    layer = mesh.attributes.new(name=name, type='FLOAT', domain='POINT')
    layer.data.foreach_set("value", array.astype(np.float32).tolist())
    return int(known.sum()), array


def plain(obj):
    """Strip a previous coloring so a flat `color=` decides the object again."""
    layers = obj.data.color_attributes
    for name in [a.name for a in layers]:
        layers.remove(layers[name])
    for key in ("scigraphs_last_color_attribute", "scigraphs_color_attr"):
        if key in obj.keys():
            del obj[key]
    obj.data.materials.clear()
    return obj


def luminance(path):
    """(rgb, luminance) for a PNG, on the 0-255 scale it was written in."""
    from PIL import Image
    rgb = np.asarray(Image.open(str(path)).convert("RGB"), dtype=np.float32)
    return rgb, 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]


def overlay(base, highlight, attribute, filename, extra=(),
            node_fraction=NODE_FRACTION, fat=FAT, clip_high_pct=None):
    """Draw `highlight` over `base`, and measure whether it reads.

    Returns the render's path and the two luminances that decide the figure:
    the median of the pixels the highlight reaches (found by rendering the same
                                                    frame again with it hidden) and the median of the context's own ink.
    """
    sizes = sg.render.autoscale_geometry(base, node_fraction=node_fraction,
                                         verbose=False)
    for obj, ratio, color in [(highlight, fat, CONTEXT), *extra]:
        sg.render.autoscale_geometry(obj, node_fraction=node_fraction,
                                     verbose=False)
        obj["scigraphs_node_size"] = sizes["node_radius"] * 0.02
        obj["scigraphs_edge_thickness"] = sizes["edge_radius"] * ratio
        sg.render.material(obj, color=color)
        sg.render.geometry_nodes(obj)
    # `nodes_only` defaults True for a point attribute. These spheres were
    # just shrunk to nothing, so the colormap has to reach the tubes.
    sg.render.color_graph(highlight, attribute, colormap="turbo",
                          nodes_only=False, clip_high_pct=clip_high_pct,
                          verbose=False)

    shown = {base, highlight, *(o for o, _, _ in extra)}
    hidden = [o for o in LAYERS if o not in shown]

    plain(base)
    # `shrink_png` picks its palette per image, so a pair compared pixel by
    # pixel stays unquantized until after the measurement.
    full = nb.render(
        base, filename, look='ink', isolate=False, color=CONTEXT,
        node_fraction=node_fraction, hide=hidden, verbose=False, shrink=False)
    reference = nb.render(
        base, filename + "_context", look='ink', isolate=False, color=CONTEXT,
        node_fraction=node_fraction, hide=hidden + [highlight], verbose=False,
        shrink=False)
    rgb_full, lum_full = luminance(full)
    rgb_ref, lum_ref = luminance(reference)
    reached = np.abs(lum_full - lum_ref) > 8
    ink_ref = np.abs(rgb_ref - rgb_ref.reshape(-1, 3)[0]).sum(axis=2) > 24
    numbers = {
        "highlight_median": float(np.median(lum_full[reached])),
        "highlight_p05": float(np.percentile(lum_full[reached], 5)),
        "highlight_area": float(reached.mean()),
        "context_median": float(np.median(lum_ref[ink_ref])),
    }
    numbers["separation"] = numbers["highlight_median"] - numbers["context_median"]
    plain(base)
    sg.preview.shrink_png(full)
    sg.preview.shrink_png(reference)
    return full, numbers


def report(numbers, label_a, label_b):
    print(f"  {label_a:<26} median luminance "
          f"{numbers['highlight_median']:.0f}, dimmest 5% at "
          f"{numbers['highlight_p05']:.0f}, {numbers['highlight_area']*100:.1f}% "
          f"of the frame")
    print(f"  {label_b:<26} median luminance {numbers['context_median']:.0f}")
    print(f"  {'separation':<26} {numbers['separation']:+.0f} levels")

The network

graph_from_point with the literal coordinates above is a cache hit: it is the query notebook 17 makes. The operator route would not be, because props.osmnx_latitude is single precision, so 39.4699 goes over the wire as 39.46989822387695 and keys a second cache entry (notebook 06 derives it). One more reason this notebook materializes rather than imports.

t0 = time.time()
G = ox.graph_from_point(CENTER, dist=RADIUS_M, network_type="walk",
                        simplify=True)
print(f"{time.time() - t0:.1f} s")
print(f"walk network: {G.number_of_nodes()} intersections, "
      f"{G.number_of_edges()} directed segments, crs {G.graph.get('crs')}")
print("edge attributes:", sorted(next(iter(G.edges(data=True)))[2].keys()))

obj_net = materialize(G, "Walk_CiutatVella")
print(sg.graphs.summary(obj_net))

# `sg.preview.extent()` reads `obj["node_positions"]`, which
# `create_osmnx_graph_mesh` never writes
# (`SciGraphs/core/mesh/geometry.py:1750`), so it falls back to all mesh
# vertices and measures centerline sampling instead of junction spacing: node
# radii come out about 3.8x too small. `sg.render.node_cloud()` reads the
# `is_intersection` layer that importer does write.
cloud, cloud_source = sg.render.node_cloud(obj_net)
(_c, _s, _d, median_nn), _src = sg.render.measure(obj_net)
print(f"mesh {len(obj_net.data.vertices)} vertices, node cloud {len(cloud)} "
      f"via '{cloud_source}', median nearest neighbor {median_nn * 1000:.1f} m")
check("the node cloud is the junctions, not the street shape points",
      cloud_source == "is_intersection" and len(cloud) == obj_net["num_nodes"])
1.5 s
walk network: 1472 intersections, 4396 directed segments, crs epsg:4326
edge attributes: ['highway', 'length', 'oneway', 'osmid', 'reversed']

Creating OSMnx graph: 1,472 nodes, 4,396 edges...
  Created 1,472 intersection vertices
    Created 1,000 edges...
    Created 2,000 edges...
  Created 2,178 street edges in 0.29s
Total OSMnx graph creation time: 0.29s
{'object': 'Walk_CiutatVella', 'type': 'MESH', 'num_nodes': 1472, 'num_edges': 4396, 'is_directed': True, 'vertices': 3444, 'mesh_edges': 4150, 'attributes': ['is_intersection', 'position', '.edge_verts', '.corner_vert', '.corner_edge']}
mesh 3444 vertices, node cloud 1472 via 'is_intersection', median nearest neighbor 7.6 m
[PASS] the node cloud is the junctions, not the street shape points
True

1 · What osmnx_isochrones computes, and on what cost

Notebook 17 records that c2g_graph_tool_apply with ISOCHRONE rebuilds the graph from the Blender mesh, whose edges carry no attribute, so NetworkX falls back to weight 1 and the threshold counts hops while the panel calls it a distance. SCIGRAPHS_OT_OSMnxIsochrones never looks at the mesh: it pulls the MultiDiGraph out of the importer’s cache (accessibility_operators.py:88) and hands it to make_iso_polygons, which runs single_source_dijkstra_path_length with a weight function (SciGraphs/core/osmnx/accessibility.py:96-117) reading travel_time off each edge and, where there is none, deriving one from length at the panel’s speed. length is the great-circle length OSMnx computed at download time, in meters, so the threshold really is minutes. The one place in the suite where the OSMnx operator is more faithful than the city2graph one.

def edge_travel_time(_u, _v, data):
    """`_edge_weight` from `accessibility.py:96`, rewritten to be readable.

    NetworkX hands a MultiDiGraph's parallel edges in as a dict of dicts; the
    cheapest of them wins.
    """
    if data and all(isinstance(value, dict) for value in data.values()):
        candidates = list(data.values())
    else:
        candidates = [data]
    best = math.inf
    for edge in candidates:
        seconds = edge.get("travel_time")
        if seconds is None:
            seconds = (edge.get("length", 0.0) or 0.0) / SPEED_MS
        best = min(best, seconds)
    return best


lengths = np.array([d["length"] for *_e, d in G.edges(data=True)])
print(f"segment length: median {np.median(lengths):.1f} m, "
      f"total {lengths.sum() / 1000:.2f} km")
print(f"at {SPEED_KPH} km/h that is {np.median(lengths) / SPEED_MS:.1f} s "
      f"for the median segment")
check("no edge carries a travel_time yet",
      not any("travel_time" in d for *_e, d in G.edges(data=True)),
      "so the operator is about to impute all of them")
segment length: median 20.9 m, total 127.53 km
at 4.8 km/h that is 15.6 s for the median segment
[PASS] no edge carries a travel_time yet — so the operator is about to impute all of them
True

The speed is written once and never again

Before the Dijkstra the operator calls add_travel_time_from_speed (accessibility_operators.py:103), which writes travel_time onto every edge of the cached graph, guarded by if "travel_time" not in data or data["travel_time"] is None (accessibility.py:27): the docstring says it does not overwrite existing values.

The consequence is not in the docstring. After the first run every edge has a travel_time, so a second run at a different speed imputes zero edges and returns the same isochrone; make_iso_polygons takes travel_speed_kph too and uses it only for edges still missing one. Change the speed, press the button, and the picture does not move. Nothing reports it.

added = sg_access.add_travel_time_from_speed(G, SPEED_KPH)
times = np.array([d["travel_time"] for *_e, d in G.edges(data=True)])
print(f"imputed travel_time on {added} of {G.number_of_edges()} edges")
print(f"implied speed: {np.median(lengths / times):.4f} m/s "
      f"(asked for {SPEED_MS:.4f})")

again = sg_access.add_travel_time_from_speed(G, 15.0)
times_after = np.array([d["travel_time"] for *_e, d in G.edges(data=True)])
print(f"a second call at 15 km/h imputed {again} edges and changed "
      f"{int((times_after != times).sum())} of {len(times)}")
check("the imputed speed is sticky: the second call is a no-op",
      again == 0 and not (times_after != times).any(),
      "the panel's speed field does nothing after the first run")
imputed travel_time on 4396 of 4396 edges
implied speed: 1.3333 m/s (asked for 1.3333)
a second call at 15 km/h imputed 0 edges and changed 0 of 4396
[PASS] the imputed speed is sticky: the second call is a no-op — the panel's speed field does nothing after the first run
True

The distances, and what bounds them

center_node = ox.distance.nearest_nodes(G, X=CENTER[1], Y=CENTER[0])
print(f"center node {center_node}: out-degree {G.out_degree(center_node)}, "
      f"in-degree {G.in_degree(center_node)}")

seconds = nx.single_source_dijkstra_path_length(G, center_node,
                                                weight=edge_travel_time)
reach = np.array(sorted(seconds.values()))
print(f"\nreachable at all: {len(seconds)} of {G.number_of_nodes()}")
for budget in (300, 600, 900):
    n = int((reach <= budget).sum())
    print(f"  <= {budget:4d} s ({budget // 60:2d} min): {n:5d} nodes "
          f"({100 * n / G.number_of_nodes():5.1f}%)")
print(f"\neccentricity from this node: {reach.max():.0f} s "
      f"= {reach.max() * SPEED_MS:.0f} m of network")
center node 5902851069: out-degree 3, in-degree 3

reachable at all: 1472 of 1472
  <=  300 s ( 5 min):   332 nodes ( 22.6%)
  <=  600 s (10 min):  1230 nodes ( 83.6%)
  <=  900 s (15 min):  1472 nodes (100.0%)

eccentricity from this node: 858 s = 1144 m of network

A fifteen-minute isochrone on a 600 m download is a picture of the download. At 4.8 km/h a 900 s budget buys 1200 m along the network, and the furthest intersection here is 858 s away, so the 15-minute ring below contains every node there is and its boundary is the edge of the Overpass query. The operator reports three polygons and two of them mean what they say.

The download radius has to exceed speed x threshold, with slack for streets not being straight: 1200 m of network fits inside about 900 m of radius, so a 600 m download can honestly answer 10 minutes, not 15.

Notebook 06’s GraphML cache key has the same trap from the other side (SciGraphs/core/osmnx/cache.py:78-105 omits the radius): a cache hit can hand back a graph downloaded at a different radius, and an isochrone cannot tell the difference. Check the node and edge counts against what you asked for.

The two thresholds this notebook can defend are 5 and 10 minutes. Section 6 keeps 15 only to compare against notebook 17’s answer.

check("15 minutes saturates this download",
      int((reach <= THRESHOLD_S).sum()) == G.number_of_nodes(),
      f"{reach.max():.0f} s eccentricity against a {THRESHOLD_S} s budget")
check("10 minutes does not",
      int((reach <= 600).sum()) < G.number_of_nodes(),
      f"{int((reach <= 600).sum())} of {G.number_of_nodes()} nodes")
[PASS] 15 minutes saturates this download — 858 s eccentricity against a 900 s budget
[PASS] 10 minutes does not — 1230 of 1472 nodes
True

The first figure

Straight down through an orthographic camera (render_eevee’s default, never overridden in this suite), so this is a plan of Ciutat Vella and can be measured off the page.

The turbo ramp carries node_travel_time_s, the walking seconds from the center intersection to every other one, as a point attribute. There is no per-edge color on a graph: a graph mesh has no faces, so SciGraphs/core/coloring/attributes.py:227 averages an EDGE attribute over the edges meeting at each vertex, and on a street network only 44 % of tubes come out constant. nodes_only=False lets the tubes take the node values, so each tube interpolates between two real costs.

written, values = node_attribute(obj_net, seconds, "node_travel_time_s")
print(f"node_travel_time_s written on {written} intersections, "
      f"{len(obj_net.data.vertices) - written} shape vertices interpolated")
print("domain:", sg.render.attribute_domain(obj_net, "node_travel_time_s"))

nb.figure(obj_net, f"renders/{RENDERS}/1_travel_time",
          look='ink', color_attribute="node_travel_time_s",
          nodes_only=False, node_fraction=NODE_FRACTION)
node_travel_time_s written on 1472 intersections, 1972 shape vertices interpolated
domain: POINT
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
Info: Attribute -> node_travel_time_s  ·  node_travel_time_s -> turbo [log]  [0 … 857.9] on Point

[PASS] 1_travel_time.png legible — 8.4% ink (healthy range 0.5–60%)

2 · From a set of nodes to a polygon

The Dijkstra is the honest half. Turning the reachable nodes into an area is the other, and make_iso_polygons offers two ways; both have arithmetic in them that is wrong.

  • CONVEX_HULL takes the convex hull of the reachable nodes. Fast, and it claims reachability across every concavity in the city: a river bend, a walled block, the far side of a barrier.
  • BUFFER_UNION buffers each reachable node and edge by buffer_m and unions the result. Much closer to the truth, and the default.
polygons = {}
for mode in ("CONVEX_HULL", "BUFFER_UNION"):
    rings = sg_access.make_iso_polygons(G, center_node, [5, 10, 15],
                                        travel_speed_kph=SPEED_KPH, mode=mode,
                                        buffer_m=25.0)
    for entry in rings:
        polygons[(mode, entry["time"])] = entry["polygon"]

print(f"{'minutes':>8}  {'convex hull':>12}  {'buffer union':>13}  {'hull is':>9}")
for minutes in (5, 10, 15):
    hull = transform(to_utm.transform, polygons[("CONVEX_HULL", minutes)]).area
    buf = transform(to_utm.transform, polygons[("BUFFER_UNION", minutes)]).area
    print(f"{minutes:>8}  {hull / 1e6:>11.4f} km2  {buf / 1e6:>12.4f} km2  "
          f"{100 * (hull - buf) / buf:>8.1f}%")
 minutes   convex hull   buffer union    hull is
       5       0.3096 km2        0.2980 km2       3.9%
      10       1.1800 km2        1.0206 km2      15.6%
      15       1.3712 km2        1.1861 km2      15.6%

The holes the operator fills in

A buffer union over a street network is full of holes: every city block the buffer does not reach is an interior ring, the parts of the area you cannot walk on. The operator throws them away, building each mesh from p.exterior.coords alone (accessibility_operators.py:154-159), so the drawn isochrone is the outer boundary with every courtyard filled solid.

for minutes in (5, 10, 15):
    poly = transform(to_utm.transform, polygons[("BUFFER_UNION", minutes)])
    parts = list(getattr(poly, "geoms", [poly]))
    holes = sum(len(part.interiors) for part in parts)
    exterior = unary_union([Polygon(part.exterior) for part in parts])
    print(f"  {minutes:2d} min: {holes:3d} interior rings, "
          f"{poly.area / 1e6:.4f} km2 true against "
          f"{exterior.area / 1e6:.4f} km2 drawn "
          f"(+{100 * (exterior.area - poly.area) / poly.area:.1f}%)")

check("the buffer union really is full of holes",
      sum(len(p.interiors) for p in [transform(to_utm.transform,
      polygons[("BUFFER_UNION", 15)])]) > 50,
      "and the operator draws only the outer ring of each")
   5 min:  28 interior rings, 0.2980 km2 true against 0.3136 km2 drawn (+5.2%)
  10 min:  87 interior rings, 1.0206 km2 true against 1.1242 km2 drawn (+10.2%)
  15 min: 105 interior rings, 1.1861 km2 true against 1.3167 km2 drawn (+11.0%)
[PASS] the buffer union really is full of holes — and the operator draws only the outer ring of each
True

buffer_m is not meters, and on a projected graph it is

nothing at all

accessibility.py:154 and :163 both buffer by buffer_m / 111000.0, with the comment # deg approx: 111 km is roughly one degree of latitude. But shapely buffers isotropically in whatever units the geometry is in, and one degree of longitude is 111 km only at the equator. At this latitude the same number of degrees is a fifth shorter east to west, so the buffer is an ellipse and the isochrone is systematically narrower across than it is up.

extent = sg_metadata.get_graph_extent(G)
lat0, lon0 = extent["center_lat"], extent["center_lon"]
degrees = 25.0 / 111000.0
_a, _b, north_south = GEOD.inv(lon0, lat0, lon0, lat0 + degrees)
_a, _b, east_west = GEOD.inv(lon0, lat0, lon0 + degrees, lat0)
print(f"buffer_m = 25 m at latitude {lat0:.4f} becomes "
      f"{north_south:.2f} m north-south and {east_west:.2f} m east-west "
      f"({100 * (1 - east_west / north_south):.1f}% narrower)")
buffer_m = 25 m at latitude 39.4710 becomes 25.01 m north-south and 19.38 m east-west (22.5% narrower)

The same constant makes the default mode unusable on a projected graph. Nothing stops you projecting first (notebook 07 and SciGraphs/core/osmnx/edge_attributes.py:44-51 give reasons to), but make_iso_polygons has no is_projected branch. On a graph in meters the buffer is 0.000225 m, and the union of a few thousand hairlines is not a polygon of anything.

G_projected = ox.project_graph(G)
projected = sg_access.make_iso_polygons(G_projected, center_node, [10],
                                        travel_speed_kph=SPEED_KPH,
                                        mode="BUFFER_UNION", buffer_m=25.0)
collapsed = projected[0]["polygon"]
print(f"projected graph crs: {G_projected.graph['crs']}")
print(f"BUFFER_UNION on it: {collapsed.geom_type}, area {collapsed.area:.1f} m2")
print(f"the same threshold unprojected: "
      f"{transform(to_utm.transform, polygons[('BUFFER_UNION', 10)]).area:,.0f} m2")
check("BUFFER_UNION collapses on a projected graph",
      collapsed.area < 1000.0,
      "buffer_m / 111000 is 0.000225 in meters")
projected graph crs: EPSG:32630
BUFFER_UNION on it: Polygon, area 23.4 m2
the same threshold unprojected: 1,020,600 m2
[PASS] BUFFER_UNION collapses on a projected graph — buffer_m / 111000 is 0.000225 in meters
True

The edges it buffers are not the streets

The mildest of the three approximations. accessibility.py:156-163 draws each reachable edge as a straight LineString between its endpoints, ignoring the geometry OSMnx keeps for it. On a simplified graph an edge follows the bend of the street, so a curved street can leave the polygon that is supposed to contain it. Measured against the real centerlines:

reachable_10 = {n for n, s in seconds.items() if s <= 600}
edges_gdf = ox.graph_to_gdfs(G, nodes=False).to_crs(METRIC_CRS)
inside = edges_gdf[[u in reachable_10 and v in reachable_10
                   for u, v, _k in edges_gdf.index]]
poly_10 = transform(to_utm.transform, polygons[("BUFFER_UNION", 10)])
escaped = inside.geometry.difference(poly_10).length.sum()
print(f"reachable street length at 10 min: {inside.length.sum():,.0f} m")
print(f"falling outside the drawn polygon: {escaped:,.0f} m "
      f"({100 * escaped / inside.length.sum():.1f}%)")
reachable street length at 10 min: 108,908 m
falling outside the drawn polygon: 1,079 m (1.0%)

The reachable set as geometry, not as a flag

Membership in an isochrone is binary, and both ends of turbo are its darkest stops, so a 0/1 attribute puts the two halves of the answer at nearly the same luminance; this suite has produced two near-illegible plates that way. Notebook 04 moved membership to geometry instead: the subset as a second object with thicker tubes on the same origin, the context flattened to a near-neutral, color left to carry a magnitude, here the travel time.

The subset is an induced subgraph through the same importer, so it lands exactly on top of the whole network. The 10-minute set carries the ramp; the flat neutral underneath is the whole network, which is the 15-minute set entire. The outer tier is not a ring because it has run out of city to be a ring in.

reachable_5 = {n for n, s in seconds.items() if s <= 300}
obj_10 = materialize(G.subgraph(reachable_10).copy(), "Reach_10min")
node_attribute(obj_10, seconds, "node_travel_time_s")
print(f"10-minute subgraph: {obj_10['num_nodes']} nodes, {obj_10['num_edges']} edges")
print(f" 5-minute subgraph: {len(reachable_5)} nodes")
print(f"15-minute subgraph: {G.number_of_nodes()} nodes, the whole network")

path, numbers = overlay(obj_net, obj_10, "node_travel_time_s",
                        f"renders/{RENDERS}/2_isochrone_bands")
nb.show(path)
nb.check_render(path)
report(numbers, "10-minute set", "network (= 15 minutes)")

Creating OSMnx graph: 1,230 nodes, 3,708 edges...
  Created 1,230 intersection vertices
    Created 1,000 edges...
  Created 1,839 street edges in 0.20s
Total OSMnx graph creation time: 0.20s
10-minute subgraph: 1230 nodes, 3708 edges
 5-minute subgraph: 332 nodes
15-minute subgraph: 1472 nodes, the whole network
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
Info: Attribute -> node_travel_time_s  ·  node_travel_time_s -> turbo [log]  [0 … 599.8] on Point
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added

[PASS] 2_isochrone_bands.png legible — 14.0% ink (healthy range 0.5–60%)
  10-minute set              median luminance 78, dimmest 5% at 45, 9.7% of the frame
  network (= 15 minutes)     median luminance 47
  separation                 +32 levels

3 · Ego subgraphs: a hop is not a distance

osmnx_ego_subgraph keeps the nodes reachable within a radius of a center. The dialog labels its field Radius (m) and the operator hardcodes distance_attr="length" (accessibility_operators.py:221), so from the interface the radius is always meters. The function underneath (accessibility.py:39-50) documents distance_attr=None for nx.ego_graph’s hop counting, which nothing in the panel can reach.

That is the right default. A hop is one simplified segment, and a simplified segment is not a unit of anything: on this graph the shortest fifth are under 3 m and the longest is over 300.

What a hop radius buys, in meters

print("segment length, meters: "
      + "  ".join(f"p{q}={np.percentile(lengths, q):.1f}"
      for q in (5, 50, 95, 100)))
print()
print(f"{'hops':>5}  {'nodes':>6}  {'reach: median':>14}  {'max':>9}")
hop_sets = {}
meters_from_center = nx.single_source_dijkstra_path_length(G, center_node,
                                                           weight="length")
for hops in (4, 8, 12, 16, 24):
    sub = nx.ego_graph(G, center_node, radius=hops)
    hop_sets[hops] = set(sub.nodes)
    spans = [meters_from_center[n] for n in sub.nodes]
    print(f"{hops:>5}  {sub.number_of_nodes():>6}  {np.median(spans):>11.0f} m  "
          f"{max(spans):>7.0f} m")

print(f"\n{'meters':>7}  {'nodes':>6}  {'closest hop radius':>20}  {'nodes':>6}  "
      f"{'Jaccard':>8}")
meter_sets = {}
for meters in (200.0, 300.0, 400.0):
    sub = sg_access.ego_subgraph(G, center_node, meters, distance_attr="length")
    meter_sets[meters] = set(sub.nodes)
    best = min(hop_sets, key=lambda h: abs(len(hop_sets[h]) - len(meter_sets[meters])))
    a, b = meter_sets[meters], hop_sets[best]
    print(f"{meters:>6.0f}   {len(a):>6}  {best:>16} hops  {len(b):>6}  "
          f"{len(a & b) / len(a | b):>8.2f}")

BEST_HOPS = min(hop_sets, key=lambda h: abs(len(hop_sets[h]) - len(meter_sets[300.0])))
overlap = (len(meter_sets[300.0] & hop_sets[BEST_HOPS])
           / len(meter_sets[300.0] | hop_sets[BEST_HOPS]))
check("hops and meters do not select the same nodes", overlap < 0.75,
      f"the closest hop radius by size ({BEST_HOPS}) still disagrees on "
      f"{100 * (1 - overlap):.0f}% of the union")
segment length, meters: p5=2.8  p50=20.9  p95=81.2  p100=321.3

 hops   nodes   reach: median        max
    4      35           72 m      272 m
    8     145          176 m      434 m
   12     361          286 m      878 m
   16     713          410 m     1074 m
   24    1263          553 m     1144 m

 meters   nodes    closest hop radius   nodes   Jaccard
   200       87                 4 hops      35      0.34
   300      198                 8 hops     145      0.61
   400      332                12 hops     361      0.66
[PASS] hops and meters do not select the same nodes — the closest hop radius by size (8) still disagrees on 39% of the union
True

A hop radius grows fastest where the segments are shortest, in the dense core where a pedestrian crossing is its own node, and stalls along a long peripheral street, which is backwards from what a catchment means. Hence the furthest node of a hop-bounded set sitting two to four times as far as its median one.

Out is not in

nx.ego_graph on a MultiDiGraph follows edges forwards, so osmnx_ego_subgraph returns where you can get to from here, not who can reach you. On a pedestrian network the two coincide, because OSMnx marks every walk edge two-way; on a drive network they do not, and nothing in the operator, the panel or the report says which question it answered.

The drive network here is also a cache hit, from notebook 07.

Gd = ox.graph_from_point(CENTER, dist=RADIUS_M, network_type="drive",
                         simplify=True)
print(f"drive network: {Gd.number_of_nodes()} nodes, {Gd.number_of_edges()} edges, "
      f"{sum(1 for n in Gd.nodes if Gd.out_degree(n) == 0)} sinks, "
      f"{sum(1 for n in Gd.nodes if Gd.in_degree(n) == 0)} sources")

# The node nearest the center is a sink, so an ego subgraph rooted there is one
# node and no edges, reported as success: `osmnx_ego_subgraph` only rejects an
# empty result (`accessibility_operators.py:222`), the same missing check
# notebook 06 finds in `osmnx_truncate_distance`. Below uses the node with the
# most balanced reach in both directions instead.
naive = ox.distance.nearest_nodes(Gd, X=CENTER[1], Y=CENTER[0])
print(f"nearest node to the center: {naive}, out-degree "
      f"{Gd.out_degree(naive)}, in-degree {Gd.in_degree(naive)}")
print(f"  ego subgraph rooted there, 400 m: "
      f"{sg_access.ego_subgraph(Gd, naive, 400.0).number_of_nodes()} node, "
      f"reported as a result")

reversed_drive = Gd.reverse(copy=False)
usable = max(Gd.nodes, key=lambda n: min(len(nx.descendants(Gd, n)),
             len(nx.descendants(reversed_drive, n))))
print(f"node used below: {usable}, reaches {len(nx.descendants(Gd, usable))}, "
      f"reached by {len(nx.descendants(reversed_drive, usable))}")

print(f"\n{'radius':>7}  {'outbound':>9}  {'inbound':>8}  {'both':>6}  "
      f"{'one way only':>13}")
for meters in (200.0, 400.0, 600.0):
    out = set(nx.ego_graph(Gd, usable, radius=meters, distance="length").nodes)
    into = set(nx.ego_graph(reversed_drive, usable, radius=meters,
               distance="length").nodes)
    print(f"{meters:>6.0f}   {len(out):>9}  {len(into):>8}  {len(out & into):>6}  "
          f"{len(out ^ into):>13}")

out_400 = set(nx.ego_graph(Gd, usable, radius=400.0, distance="length").nodes)
in_400 = set(nx.ego_graph(reversed_drive, usable, radius=400.0,
             distance="length").nodes)
check("the drive ego subgraph is directional",
      len(out_400 ^ in_400) > 0,
      f"{len(out_400 ^ in_400)} nodes are reachable in one direction only")
drive network: 193 nodes, 292 edges, 10 sinks, 16 sources
nearest node to the center: 5902824970, out-degree 0, in-degree 1
  ego subgraph rooted there, 400 m: 1 node, reported as a result
node used below: 25767293, reaches 166, reached by 165

 radius   outbound   inbound    both   one way only
   200           4         3       1              5
   400          14        21       1             33
   600          31        51       5             72
[PASS] the drive ego subgraph is directional — 33 nodes are reachable in one direction only
True

One more consequence, and it is not local

osmnx_ego_subgraph writes the truncated graph back into the importer’s cache (accessibility_operators.py:226) and updates the object’s node and edge counts, but does not rebuild the mesh; the Blender object still holds every street of the original download. Notebook 06 documents the same fault across graph_operators.py:477-480, 518-521, 575-578, 663-666, 707-710, 754-757 and spatial_operators.py:949-952, 1032-1035. Here it has a second effect.

Every operator that puts something in the viewport derives the local origin from get_graph_extent(G), the mean of the graph’s node coordinates (SciGraphs/core/osmnx/metadata.py:33-34), while the mesh was built from the mean of the original node set. Truncate the graph and the two part company, so an isochrone generated afterwards is drawn from a different origin than the network it sits on.

truncated = sg_access.ego_subgraph(G, center_node, 300.0, distance_attr="length")
before = sg_metadata.get_graph_extent(G)
after = sg_metadata.get_graph_extent(truncated)
_a, _b, drift = GEOD.inv(before["center_lon"], before["center_lat"],
                         after["center_lon"], after["center_lat"])
print(f"node centroid before: {before['center_lat']:.6f}, {before['center_lon']:.6f}")
print(f"node centroid after : {after['center_lat']:.6f}, {after['center_lon']:.6f}")
print(f"anything drawn from the truncated graph lands {drift:.1f} m off the mesh")
check("truncation moves the origin the viewport geometry is built on",
      drift > 10.0, f"{drift:.1f} m on a 1.2 km frame")
node centroid before: 39.470956, -0.377150
node centroid after : 39.470301, -0.376333
anything drawn from the truncated graph lands 101.1 m off the mesh
[PASS] truncation moves the origin the viewport geometry is built on — 101.1 m on a 1.2 km frame
True

The figure

Three tiers on the walk network: the whole thing in the flat neutral, the hop-bounded ego in a mid gray, the 300 m ego carrying the ramp. The two subsets are as close in size as the hop ladder allows, so anything the figure shows is a difference in shape, not in how much was kept. The ramp is the network distance from the center, the quantity a hop radius does not know about.

obj_ego_m = materialize(G.subgraph(meter_sets[300.0]).copy(), "Ego_300m")
obj_ego_h = materialize(G.subgraph(hop_sets[BEST_HOPS]).copy(),
                        f"Ego_{BEST_HOPS}hops")
node_attribute(obj_ego_m, meters_from_center, "node_meters")
print(f"300 m ego: {obj_ego_m['num_nodes']} nodes; "
      f"{BEST_HOPS}-hop ego: {obj_ego_h['num_nodes']} nodes; "
      f"in both: {len(meter_sets[300.0] & hop_sets[BEST_HOPS])}; "
      f"in one only: {len(meter_sets[300.0] ^ hop_sets[BEST_HOPS])}")

path, numbers = overlay(obj_net, obj_ego_m, "node_meters",
                        f"renders/{RENDERS}/3_ego_hops_vs_metres",
                        extra=[(obj_ego_h, 2.2, (0.42, 0.42, 0.48))])
nb.show(path)
nb.check_render(path)
report(numbers, "300 m ego", "walk network")

Creating OSMnx graph: 198 nodes, 566 edges...
  Created 198 intersection vertices
  Created 282 street edges in 0.01s
Total OSMnx graph creation time: 0.01s

Creating OSMnx graph: 145 nodes, 408 edges...
  Created 145 intersection vertices
  Created 204 street edges in 0.00s
Total OSMnx graph creation time: 0.00s
300 m ego: 198 nodes; 8-hop ego: 145 nodes; in both: 130; in one only: 83
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
Info: Attribute -> node_meters  ·  node_meters -> turbo [log]  [0 … 298.9] on Point
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added

[PASS] 3_ego_hops_vs_metres.png legible — 7.3% ink (healthy range 0.5–60%)
  300 m ego                  median luminance 86, dimmest 5% at 46, 1.3% of the frame
  walk network               median luminance 47
  separation                 +38 levels

4 · The places, and getting them onto the network

Six operators download OSM features: osmnx_features_place, osmnx_features_point, osmnx_features_bbox, osmnx_features_address, osmnx_features_polygon (a Blender mesh reprojected) and osmnx_features_xml (a local .osm file, no request at all). Six front doors onto one Overpass request, differing only in how the area is named, all resolving a tag dict through SciGraphs/core/feature_tags.py.

feature_tags = importlib.import_module("scigraphs_core.feature_tags")
print("presets:")
for name, tags in feature_tags.FEATURE_TAG_PRESETS.items():
    print(f"  {name:<18} {tags}")
presets:
  BUILDING           {'building': True}
  AMENITY            {'amenity': True}
  RESTAURANT         {'amenity': ['restaurant']}
  SHOP               {'shop': True}
  LEISURE            {'leisure': True}
  PARKING            {'amenity': ['parking']}
  BUS_STOP           {'highway': ['bus_stop']}
  RAIL_STATION       {'railway': ['station', 'halt', 'stop', 'tram_stop']}
  PARK               {'leisure': ['park', 'garden', 'nature_reserve', 'playground']}
  EDUCATION          {'amenity': ['school', 'university', 'college', 'kindergarten', 'library']}
  HEALTH             {'amenity': ['hospital', 'clinic', 'doctors', 'pharmacy', 'dentist']}
  AMENITY_METAPATH   {'amenity': ['cafe', 'restaurant', 'pub', 'bar', 'museum', 'theatre', 'cinema']}
  LANDUSE            {'landuse': True}
  NATURAL            {'natural': True}
  WATER              {'natural': ['water']}
  HIGHWAY            {'highway': True}

The custom-tag parser cannot express a multi-value tag. tags_from_preset splits on commas and then on the first =, and assigns into a dict (feature_tags.py:88-97). So amenity=cafe,amenity=bar does not mean “cafes and bars”: the second assignment replaces the first and you get bars. Every multi-value query in the add-on is therefore a preset, and CUSTOM can only ever ask for one value per key. Nothing warns.

print("amenity=cafe,amenity=bar  ->",
      feature_tags.tags_from_preset('CUSTOM', "amenity=cafe,amenity=bar"))
print("shop=true,amenity=bar     ->",
      feature_tags.tags_from_preset('CUSTOM', "shop=true,amenity=bar"))
check("a repeated key silently keeps only the last value",
      feature_tags.tags_from_preset('CUSTOM', "amenity=cafe,amenity=bar")
      == {"amenity": "bar"})
amenity=cafe,amenity=bar  -> {'amenity': 'bar'}
shop=true,amenity=bar     -> {'shop': True, 'amenity': 'bar'}
[PASS] a repeated key silently keeps only the last value
True

One real call

osmnx_features_point with the RESTAURANT preset, the tightest tag set in the table, over the same 600 m, so the download path is exercised end to end. Overpass is a shared public service and this is the only request the suite adds to it, so the tags are one key with one value and the radius is the study radius and not a meter more. It is wrapped because Errno 111 and HTTP 429 are the server rather than the code; the section continues on the cached amenity set either way.

feat_source has to be set first, and it is not an argument. All six operators consult context.scene.scigraphs.feat_source and, if it says OVERTURE (the default), hand the query to city2graph’s Overture reader instead (features_operators.py:234-241), whatever the operator is called. Left alone, osmnx_features_point talks to the Overture demo endpoint, which answers HTTP 400: Demo accounts can only access locations within 10,000 meters of demo cities and reports “No features found”.

props.feat_source = 'OSMNX'
props.feat_type = 'RESTAURANT'
props.feat_custom_tags = ""
props.feat_nodes_only = True

restaurants = None
try:
    result = bpy.ops.scigraphs.osmnx_features_point(
        latitude=CENTER[0], longitude=CENTER[1], distance=RADIUS_M,
        feature_type='RESTAURANT', filter_nodes_only=True)
    print("osmnx_features_point ->", result)
    restaurants = next((o for o in bpy.data.objects
                       if o.get("feature_type") == 'RESTAURANT'), None)
    if restaurants is not None:
        LAYERS.append(restaurants)
        print(f"  object {restaurants.name}: {len(restaurants.data.vertices)} "
              f"points, crs {restaurants.get('crs')}")
except RuntimeError as exc:
    print(f"the feature download did not happen: {exc}")
    print("carrying on with the cached amenity set below")
Downloaded 245 features from point
Info: Downloading features...
Info: Filtered to nodes only: 245 → 243 features
Info: Created 1 object(s) with 243 features
osmnx_features_point -> {'FINISHED'}
  object OSM_RESTAURANT: 243 points, crs epsg:4326

The coordinates that went over the wire are not the literals above. latitude and longitude are Blender FloatProperty fields, single precision, so 39.4699 becomes 39.46989822387695, and OSMnx keys its disk cache on the query string built from that. The study area moves by about a centimeter and the cache entry is a different file. So no operator-issued feature query can hit a cache entry warmed by a direct ox.features_from_point call, which is why this notebook spends one request rather than none.

The POIs the rest of the section uses

Notebook 17’s amenity query, unchanged, so it is a cache hit and section 6 is a comparison rather than an approximation.

AMENITY_TAGS = {"amenity": ["restaurant", "cafe", "bar", "pub", "cinema",
                "theatre"]}
pois = ox.features_from_point(CENTER, tags=AMENITY_TAGS, dist=RADIUS_M)
pois = pois[pois.geometry.geom_type == "Point"].copy()
print(f"{len(pois)} point amenities")
print(pois["amenity"].value_counts().to_string())

objects = sg_geo_mesh.create_feature_mesh_from_gdf(
    pois[["amenity", "geometry"]], name="Amenities", osmnx_obj=obj_net)
obj_pois = objects[0]
LAYERS.append(obj_pois)
print(f"\nBlender object {obj_pois.name}: {len(obj_pois.data.vertices)} vertices")
check("one mesh vertex per amenity",
      len(obj_pois.data.vertices) == len(pois))
395 point amenities
amenity
restaurant    243
cafe          100
bar            31
pub            18
theatre         2
cinema          1

Blender object Amenities: 395 vertices
[PASS] one mesh vertex per amenity
True

Snapping, and the distribution it produces

osmnx_snap_pois converts each vertex of the active mesh back to lon/lat, asks find_nearest_node for the closest graph node, and writes the answer as an integer point attribute. Three modes: record the id only, move the POI onto the node, or draw a connector line to it.

It does not record how far it moved anything. A restaurant 12 m from the nearest intersection is on that street corner. One 90 m from it is mid-block, or in a pedestrian precinct the walk filter did not keep, or geocoded to a building centroid whose entrance is elsewhere. Attaching it to an intersection 90 m away and computing a 15-minute reach from there adds 68 s of walking that nobody does.

Which network it snaps to is not something you choose. _find_osmnx_object (features_operators.py:13-21) returns the active object when that is a network, and otherwise the first object in bpy.data.objects carrying is_osmnx, which is alphabetical order. The active object here has to be the POI mesh, so the second branch decides. By this point the scene holds four networks (the walk graph, its 10-minute subgraph and the two ego subgraphs of section 3) and the one that wins is whichever name sorts first. Snapping 395 amenities to a 198-node fragment instead of to the network is a different answer, arrived at in silence.

def snapped_ids(obj):
    layer = obj.data.attributes["nearest_node_id"]
    return np.array([layer.data[i].value for i in range(len(obj.data.vertices))],
                    dtype=np.int64)


props.osmnx_poi_snap_mode = 'ATTR_ONLY'
candidates = [o.name for o in bpy.data.objects if o.get("is_osmnx")]
print("networks the scan can see:", candidates)
print("the one it will therefore pick:", candidates[0])

sg.graphs.activate(obj_pois)
# `find_nearest_node` logs once per call, and there are 395 calls.
with nb.quiet():
    first = bpy.ops.scigraphs.osmnx_snap_pois()
print("osmnx_snap_pois ->", first)
by_whichever = snapped_ids(obj_pois)

# The same scan with the fragments hidden; nothing else changes.
others = [o for o in bpy.data.objects
          if o.get("is_osmnx") and o is not obj_net]
for other in others:
    other["is_osmnx"] = False
sg.graphs.activate(obj_pois)
t0 = time.time()
with nb.quiet():
    second = bpy.ops.scigraphs.osmnx_snap_pois()
elapsed = time.time() - t0
for other in others:
    other["is_osmnx"] = True
print(f"osmnx_snap_pois, walk network only -> {second} in {elapsed:.1f} s "
      f"({len(pois)} POIs x {G.number_of_nodes()} nodes, one query each)")

by_walk = snapped_ids(obj_pois)
print(f"the two runs disagree on {int((by_whichever != by_walk).sum())} "
      f"of {len(by_walk)} amenities, and neither reported anything unusual")

nearest_id, nearest_m = ox.distance.nearest_nodes(
    G, X=pois.geometry.x.values, Y=pois.geometry.y.values, return_dist=True)
nearest_id = np.asarray(nearest_id, dtype=np.int64)
nearest_m = np.asarray(nearest_m, dtype=float)

print("\nsnap distance to the nearest intersection, meters:")
for q in (5, 25, 50, 75, 90, 95, 99, 100):
    print(f"  p{q:<3} {np.percentile(nearest_m, q):7.1f}")
print(f"  mean {nearest_m.mean():.1f}   over 50 m: "
      f"{int((nearest_m > 50).sum())}   over 80 m: {int((nearest_m > 80).sum())}")
networks the scan can see: ['Ego_300m', 'Ego_8hops', 'Reach_10min', 'Walk_CiutatVella']
the one it will therefore pick: Ego_300m
Nearest node to (39.47035612802079, -0.38104359582998154): 593993799
Nearest node to (39.47024962802094, -0.380592400153335): 593993799
Nearest node to (39.47052772801409, -0.3795815096319921): 593993799
Nearest node to (39.47031262802067, -0.3800625050654201): 593993799
Nearest node to (39.47035892802078, -0.37985670724375314): 593993799
Nearest node to (39.47024492801946, -0.3804027021163787): 593993799
Nearest node to (39.47023712802129, -0.3822678842740743): 593993799
Nearest node to (39.47169732802357, -0.38032940263074494): 593993789
Nearest node to (39.47084972802113, -0.38047590126262404): 593993799
Nearest node to (39.47417042791904, -0.3814178923020346): 593995484
Nearest node to (39.47360522788909, -0.38041090182342646): 593995484
Nearest node to (39.473077328025, -0.38104779584929627): 593995484
Nearest node to (39.47386112814677, -0.38160779067057266): 593995484
Nearest node to (39.47372792793315, -0.38039840210487064): 593995484
Nearest node to (39.47360432815023, -0.38000890577784685): 593995484
Nearest node to (39.47268502794617, -0.38031580299806256): 593995484
Nearest node to (39.472373227961235, -0.380439801650468): 593995484
Nearest node to (39.471229028019245, -0.37844722041140366): 569810134
Nearest node to (39.474214028133034, -0.3741347610904382): 57472035
Nearest node to (39.47328352796252, -0.3752159508108225): 57472035
Nearest node to (39.47317142805901, -0.37544024875063253): 57472035
Nearest node to (39.46750112797706, -0.3794377111365279): 25767312
Nearest node to (39.473170127900175, -0.37521325084800516): 57472035
Nearest node to (39.47081592801229, -0.37684833547527846): 7526740940
Nearest node to (39.4726479279903, -0.3753117499287348): 57472035
Nearest node to (39.472333128071774, -0.3746864558652784): 57472073
Nearest node to (39.47458432789488, -0.3809316969273586): 593995484
Nearest node to (39.467588827991946, -0.3751320516319036): 7108153402
Nearest node to (39.47035292801961, -0.3743811586937535): 1828092795
Nearest node to (39.471856128045644, -0.37565104674615774): 7580652364
Nearest node to (39.47032752802121, -0.3716163846169828): 35594393
Nearest node to (39.47334982802237, -0.3742616598636452): 57472035
Nearest node to (39.473243628015034, -0.3726145754353833): 25767442
Nearest node to (39.46707152800038, -0.37827392213573796): 6088839481
Nearest node to (39.467876728077194, -0.38045120145489725): 25767312
Nearest node to (39.46676302799599, -0.3769753342840782): 13173452314
Nearest node to (39.467122328001366, -0.3778878257168848): 6088839481
Nearest node to (39.46791512792388, -0.3759295441436979): 25767445
Nearest node to (39.46824042801196, -0.3747368554026652): 6766690060
Nearest node to (39.46646922801456, -0.3738099640524345): 7021924264
Nearest node to (39.46644172795335, -0.373850163691712): 7021924264
Nearest node to (39.46782682808308, -0.3718356827735291): 25767431
Nearest node to (39.47405632805981, -0.3789908152977919): 2938257929
Nearest node to (39.46790142814679, -0.38211068578311924): 525306352
Nearest node to (39.468424528063544, -0.3767973359413323): 7612237926
Nearest node to (39.47375062804702, -0.3767795361157376): 7024437175
Nearest node to (39.47434362805113, -0.3778848257774875): 7024437175
Nearest node to (39.47148332807113, -0.3743587588800707): 25767442
Nearest node to (39.47436392805733, -0.3813255932580838): 593995484
Nearest node to (39.473758128014985, -0.3822257847831237): 593995484
Nearest node to (39.47414352795176, -0.3762224413718634): 7024437175
Nearest node to (39.474261428048486, -0.3782348224105871): 7024437175
Nearest node to (39.470938728027335, -0.37468935581248236): 55597228
Nearest node to (39.47093492799086, -0.37444615813295107): 55597228
Nearest node to (39.47085102801245, -0.3743551589875135): 55597228
Nearest node to (39.471562428035206, -0.3742346600618744): 25767442
Nearest node to (39.473950228023455, -0.37625004111051935): 7024437175
Nearest node to (39.46421702807959, -0.37146688621930396): 7021924264
Nearest node to (39.47450422808696, -0.3768866351326846): 7024437175
Nearest node to (39.46974962801405, -0.3716685843610446): 25767434
Nearest node to (39.469834528037325, -0.3742297600972064): 25767434
Nearest node to (39.472392127971624, -0.37515915133906896): 57472035
Nearest node to (39.47394722808988, -0.37504555248236077): 57472035
Nearest node to (39.47143202808122, -0.37578064552558144): 524846175
Nearest node to (39.470021528018, -0.37526845035786666): 524846190
Nearest node to (39.473208328072914, -0.37335806843043473): 25767442
Nearest node to (39.47356082790722, -0.3768033359069257): 7024437175
Nearest node to (39.472552428094495, -0.37654883830665997): 1083021641
Nearest node to (39.47213212796553, -0.37568254645702454): 57472035
Nearest node to (39.471075828047404, -0.37572044610225797): 11918521185
Nearest node to (39.468877827997936, -0.37084289188076325): 5574601914
Nearest node to (39.47124462804909, -0.37023409739719326): 35594393
Nearest node to (39.470723328012085, -0.3712584882534155): 35594393
Nearest node to (39.469847328008555, -0.3739050632249935): 25767434
Nearest node to (39.46661872805492, -0.3743405590857027): 7021924264
Nearest node to (39.46406942802397, -0.3725711756987243): 7021924264
Nearest node to (39.47409912796989, -0.37665573729582097): 7024437175
Nearest node to (39.46759052803471, -0.37531844983968143): 7108153402
Nearest node to (39.4677064279077, -0.37548804831739535): 7108153402
Nearest node to (39.46769742810696, -0.37568524641984186): 7108153402
Nearest node to (39.46770692803064, -0.37529295009440805): 7108153402
Nearest node to (39.46749402789303, -0.38045870156378686): 25767312
Nearest node to (39.4735892279753, -0.37726373155974835): 7024437175
Nearest node to (39.46583732804083, -0.3794514111085987): 6088839481
Nearest node to (39.4724698280739, -0.37824582224624304): 593995484
Nearest node to (39.4733366281002, -0.37741543013287543): 7024437175
Nearest node to (39.46909342802217, -0.3729795719390669): 5574601914
Nearest node to (39.46759102788963, -0.3747707550129706): 6766690060
Nearest node to (39.47188752804731, -0.37008349943258295): 25767442
Nearest node to (39.47248902799724, -0.37171178376612185): 25767442
Nearest node to (39.47191492800352, -0.37041519575832854): 25767442
Nearest node to (39.473166528140695, -0.3743494589117013): 57472035
Nearest node to (39.471906828075646, -0.37541784893694974): 57472073
Nearest node to (39.46577092827801, -0.37898941540708536): 13173452314
Nearest node to (39.47433192796174, -0.3687961110537707): 25767442
Nearest node to (39.46908102800188, -0.37369196510522606): 5574601914
Nearest node to (39.47330072806415, -0.37303417149576845): 25767442
Nearest node to (39.46792912814996, -0.37071109314288825): 25767431
Nearest node to (39.47445652799053, -0.37586764472288975): 1083021641
Nearest node to (39.474299827895166, -0.37588604454504376): 1083021641
Nearest node to (39.47400232791526, -0.3767104367796168): 7024437175
Nearest node to (39.47029612802059, -0.38209638620508357): 593993799
Nearest node to (39.46848582796607, -0.376789436037267): 7612237926
Nearest node to (39.46996202802872, -0.37698853416498423): 5902851055
Nearest node to (39.46635722808203, -0.37851051980971717): 13173452314
Nearest node to (39.46694592799373, -0.37805702413902664): 6088839481
Nearest node to (39.468315028075665, -0.3786026190428789): 25767312
Nearest node to (39.46810072807626, -0.3777643268518484): 6088839481
Nearest node to (39.47421742795055, -0.3749552532822764): 57472035
Nearest node to (39.47402482808716, -0.37623484125012374): 7024437175
Nearest node to (39.47437782804441, -0.3787836172382233): 7024437175
Nearest node to (39.47110692800211, -0.3761700418604119): 25767324
Nearest node to (39.473093327938784, -0.3759443439864959): 1083021641
Nearest node to (39.47340532799971, -0.37611014243536806): 1083021641
Nearest node to (39.46865972802906, -0.37721883202698825): 7612237925
Nearest node to (39.47037662801921, -0.377027533811143): 524846143
Nearest node to (39.470994628022616, -0.3765255385198379): 524846227
Nearest node to (39.47073202803388, -0.37613814215907215): 524846227
Nearest node to (39.46515312796531, -0.3817433894589891): 6088839481
Nearest node to (39.46627722797707, -0.38015620434727254): 6088839481
Nearest node to (39.46563472813081, -0.38031180296313444): 6088839481
Nearest node to (39.47082592802541, -0.3787200178633298): 593993791
Nearest node to (39.46677192809376, -0.37976640804366885): 6088839481
Nearest node to (39.46710412792188, -0.379134413982467): 6088839481
Nearest node to (39.47425032805302, -0.3768788351774132): 7024437175
Nearest node to (39.464181327985514, -0.37127998796376077): 7021924264
Nearest node to (39.472768127955696, -0.3763099405437006): 1083021641
Nearest node to (39.46935432803514, -0.3786038189491988): 525306352
Nearest node to (39.47306612805855, -0.37404076183200574): 57472073
Nearest node to (39.472986728047516, -0.37632424040993534): 1083021641
Nearest node to (39.46474722808037, -0.3689482095956711): 25767431
Nearest node to (39.46756212796663, -0.3736738653030514): 6766690060
Nearest node to (39.46878882795823, -0.3729296723626467): 25767431
Nearest node to (39.46854412806903, -0.37432995921882006): 6766690061
Nearest node to (39.467577628025495, -0.37575594576125404): 7108153402
Nearest node to (39.467446428035615, -0.37598954356093506): 13173452314
Nearest node to (39.46769282810159, -0.3757307459925606): 7108153402
Nearest node to (39.467698927939736, -0.374374758672588): 6766690060
Nearest node to (39.46819192801366, -0.37314677034374466): 25767431
Nearest node to (39.46758572808738, -0.3754680484899499): 7108153402
Nearest node to (39.467574928004865, -0.3756812464717125): 7108153402
Nearest node to (39.46756712812395, -0.37378486427593444): 6766690060
Nearest node to (39.467588027956054, -0.37540444902825765): 7108153402
Nearest node to (39.468025027929706, -0.3763274403798311): 207515787
Nearest node to (39.46972952805032, -0.37635014016670326): 5902851069
Nearest node to (39.473464727917495, -0.3769459345394694): 7024437175
Nearest node to (39.47456922798797, -0.3753940491457617): 1083021641
Nearest node to (39.468431227995616, -0.3762733408864916): 207515787
Nearest node to (39.470291128020314, -0.37624454116014183): 11420770022
Nearest node to (39.47362912792279, -0.37676793624012295): 7024437175
Nearest node to (39.472171827971046, -0.3767113367527561): 7024437176
Nearest node to (39.47383222795575, -0.3769465345360288): 7024437175
Nearest node to (39.474617427939314, -0.37520975086084246): 1083021641
Nearest node to (39.4745306279313, -0.3749517532951137): 1083021641
Nearest node to (39.471868928083886, -0.3758861445372371): 57472035
Nearest node to (39.47074652801438, -0.3768959350142553): 7526740940
Nearest node to (39.470384928023066, -0.37497675307942047): 11918521078
Nearest node to (39.470385128023665, -0.37510835183290886): 11918521078
Nearest node to (39.46871092804799, -0.3767613362779702): 5574601866
Nearest node to (39.468374128080505, -0.3770135339058916): 525252704
Nearest node to (39.47071372801691, -0.37549534818150193): 524846166
Nearest node to (39.471923128036394, -0.369556103802306): 25767442
Nearest node to (39.46695542791741, -0.37500845277467393): 7021924264
Nearest node to (39.467062028076704, -0.37498825296284183): 7021924264
Nearest node to (39.47402902794057, -0.3805423005925282): 593995484
Nearest node to (39.47420402808641, -0.3803974021829374): 593995484
Nearest node to (39.47447832809753, -0.37678763604758897): 7024437175
Nearest node to (39.4744452280531, -0.37556144762162486): 1083021641
Nearest node to (39.47381042811677, -0.374219260222517): 57472035
Nearest node to (39.47105642804809, -0.3745433571415699): 55597228
Nearest node to (39.47223392804347, -0.37804782416285054): 593995484
Nearest node to (39.473747728084426, -0.3742316601224771): 57472035
Nearest node to (39.46908932800573, -0.37358006620260314): 5574601914
Nearest node to (39.46644612801675, -0.38133589314838645): 25767312
Nearest node to (39.4692959280282, -0.38050030109377087): 525306352
Nearest node to (39.474401828049096, -0.3810142963813618): 593995484
Nearest node to (39.47202582798721, -0.37980030782757174): 569810146
Nearest node to (39.47427482791262, -0.3697647020997763): 25767442
Nearest node to (39.4684327279624, -0.37860691888078946): 25767312
Nearest node to (39.469121727985254, -0.3786653183144349): 25767312
Nearest node to (39.473178227962066, -0.37765202789365343): 7024437175
Nearest node to (39.4733228280841, -0.3701915979374693): 25767442
Nearest node to (39.46659182808764, -0.3685566137797826): 25767431
Nearest node to (39.47328492809234, -0.3762510410975517): 1083021641
Nearest node to (39.47019602802415, -0.3764583391438887): 11420770022
Nearest node to (39.46755542803456, -0.3736260657362886): 6766690060
Nearest node to (39.47177472807889, -0.3728308731317395): 25767442
Nearest node to (39.47239802800181, -0.3765626381624257): 1083021641
Nearest node to (39.47211242805325, -0.37534724958773086): 57472073
Nearest node to (39.47201592804558, -0.3752663503482093): 57472073
Nearest node to (39.472066927988536, -0.375491848281138): 57472073
Nearest node to (39.472196727982606, -0.3776458279436734): 593995484
Nearest node to (39.471043028049934, -0.37090949154224917): 35594393
Nearest node to (39.47406762799724, -0.3741839605479076): 57472035
Nearest node to (39.470923328006464, -0.3766070377559188): 524846232
Nearest node to (39.47039332802303, -0.3742465600008678): 1828092795
Nearest node to (39.466632227890045, -0.37766732774081746): 13173452314
Nearest node to (39.46973032801921, -0.37753292903231844): 25767297
Nearest node to (39.4681091280511, -0.37337866821104004): 25767431
Nearest node to (39.46821472796451, -0.3736414655756459): 25767431
Nearest node to (39.46823132797222, -0.373542266549563): 25767431
Nearest node to (39.467104628044815, -0.37521155089391983): 7021924264
Nearest node to (39.46725062802865, -0.37515835140152237): 7021924264
Nearest node to (39.467197527890974, -0.37496955312070845): 7021924264
Nearest node to (39.467485128063274, -0.3751368516043783): 7108153402
Nearest node to (39.47271382789819, -0.37614184213062146): 1083021641
Nearest node to (39.47453642812451, -0.3749671532212699): 1083021641
Nearest node to (39.472092327989024, -0.3768064358819157): 2262122052
Nearest node to (39.473990227941925, -0.3741874604482715): 57472035
Nearest node to (39.46815072804135, -0.3729378724168896): 25767431
Nearest node to (39.465554727757834, -0.37353496659865765): 7021924264
Nearest node to (39.46544412822314, -0.3737472646072809): 7021924264
Nearest node to (39.464992727871454, -0.3699358005471889): 6766690060
Nearest node to (39.466449927986226, -0.3722828784152475): 6766690060
Nearest node to (39.468879227993746, -0.3789878153583946): 25767312
Nearest node to (39.473458828021315, -0.3763948397449138): 7024437175
Nearest node to (39.46909332805118, -0.37349766708018173): 5574601914
Nearest node to (39.47432022814037, -0.3751752512105784): 1083021641
Nearest node to (39.47353402791092, -0.3742200601600636): 57472035
Nearest node to (39.47162642795836, -0.37171258370366844): 25767442
Nearest node to (39.47189472796831, -0.37025579778631545): 25767442
Nearest node to (39.472905727964694, -0.3728178734522171): 25767442
Nearest node to (39.466254528131216, -0.3688899106750116): 25767431
Nearest node to (39.47257822811293, -0.37722513196916163): 2938257929
Nearest node to (39.466640128009956, -0.3770430336426937): 13173452314
Nearest node to (39.46601502793928, -0.3773017311971751): 13173452314
Nearest node to (39.471239628025785, -0.3765840379673764): 11918521202
Nearest node to (39.467218828143054, -0.3808524979023187): 25767312
Nearest node to (39.468739428087055, -0.37408436155305136): 5574601889
Nearest node to (39.47442122804841, -0.3789119160757344): 7024437175
Nearest node to (39.47345402807398, -0.37715893253684535): 7024437175
Nearest node to (39.47151592799263, -0.3729626722168097): 25767442
Nearest node to (39.46576112790534, -0.378230522399079): 13173452314
Nearest node to (39.47461562792556, -0.3721849794612754): 25767442
Nearest node to (39.472948427903795, -0.37483875439156544): 57472035
Nearest node to (39.47183732800624, -0.3772332318576136): 7024437178
Nearest node to (39.47316672808266, -0.3730419712340429): 25767442
Nearest node to (39.473115928081675, -0.372401077173245): 25767442
Nearest node to (39.47333132802993, -0.37332826867365326): 25767442
Nearest node to (39.47134632802205, -0.37295387220940684): 25767442
Nearest node to (39.47120792804416, -0.3708423919197967): 35594393
Nearest node to (39.46966062800784, -0.37199368120203075): 25767434
Nearest node to (39.473290428104576, -0.36983550143338184): 25767442
Nearest node to (39.47057372801749, -0.37729463131745516): 2528923280
Nearest node to (39.47187892799649, -0.37647623898337645): 11918521212
Nearest node to (39.47009332802308, -0.3766035377904558): 8205346061
Nearest node to (39.47068032802602, -0.3742555598190597): 55597192
Nearest node to (39.46831382802183, -0.3745623570470821): 6766690061
Nearest node to (39.46973342799078, -0.36990040053319095): 25767434
Nearest node to (39.474076428124036, -0.3789984152252773): 2938257929
Nearest node to (39.471054527996344, -0.37265327484456573): 35594393
Nearest node to (39.4677064279077, -0.3753574494858402): 7108153402
Nearest node to (39.47166592802191, -0.377619028213088): 5005646482
Nearest node to (39.46508862818723, -0.37030469674641214): 6766690060
Nearest node to (39.47212832799605, -0.3771904322477121): 569810150
Nearest node to (39.47280332792683, -0.37492325361044304): 57472035
Nearest node to (39.472093727984834, -0.375863644731361): 57472035
Nearest node to (39.47408672808361, -0.3782396223830618): 7024437175
Nearest node to (39.47024842802154, -0.3771280328659373): 8205346061
Nearest node to (39.467978428050145, -0.37505365237081273): 25767292
Nearest node to (39.46950552805126, -0.3784717202347438): 525306352
Nearest node to (39.4667317279653, -0.3687918113894577): 25767431
Nearest node to (39.47103112801857, -0.38190238780942404): 593993799
Nearest node to (39.472756227924336, -0.3765254385059449): 1083021641
Nearest node to (39.464281228212734, -0.37814012338039893): 5574601877
Nearest node to (39.47256772794337, -0.3755762474210235): 57472035
Nearest node to (39.473419427928754, -0.3760568429290776): 1083021641
Nearest node to (39.470076428018665, -0.3753130499140468): 11918521077
Nearest node to (39.47453622791453, -0.37633404031782597): 7024437175
Nearest node to (39.474379927971114, -0.3793025123168847): 2938257929
Nearest node to (39.466216928052404, -0.369024909163073): 25767431
Nearest node to (39.47296472799855, -0.3733745681839186): 25767442
Nearest node to (39.47286782797293, -0.3760315431681908): 1083021641
Nearest node to (39.47221802796666, -0.3707232932320592): 25767442
Nearest node to (39.47157532804442, -0.37234437778048357): 25767442
Nearest node to (39.47252972798062, -0.3765176385940729): 1083021641
Nearest node to (39.46670402796213, -0.37079679235488455): 25767431
Nearest node to (39.46871622798425, -0.3772000321926615): 7612237925
Nearest node to (39.47206832798434, -0.37636024006990687): 57472035
Nearest node to (39.47192872801962, -0.37559864723470626): 7580652364
Nearest node to (39.46567862798974, -0.3689829096643157): 25767431
Nearest node to (39.47044022801655, -0.3755596475885475): 11918521219
Nearest node to (39.469743228028435, -0.3776137282796467): 25767297
Nearest node to (39.469566528040836, -0.3776212282149387): 25767297
Nearest node to (39.474280327924866, -0.3768578353846352): 7024437175
Nearest node to (39.472941927913695, -0.374100761314342): 57472073
Nearest node to (39.469000528041995, -0.37172018380475136): 5574601914
Nearest node to (39.47059342801352, -0.3793219121911702): 7310521744
Nearest node to (39.46751342789235, -0.37328316906970777): 6766690060
Nearest node to (39.46563492807278, -0.3808261978723036): 6088839481
Nearest node to (39.47165072801001, -0.37874221769262595): 569810146
Nearest node to (39.471096028015616, -0.37648413890914145): 11918521202
Nearest node to (39.46703882810791, -0.38042450180330395): 25767312
Nearest node to (39.46753342798559, -0.3741022613708394): 6766690060
Nearest node to (39.46766722802512, -0.3736113660158821): 6766690060
Nearest node to (39.47032782802001, -0.37867401832964437): 7310521744
Nearest node to (39.47014972802405, -0.3771944321958414): 8205346061
Nearest node to (39.474188927911484, -0.37561844703436564): 1083021641
Nearest node to (39.47394752800283, -0.37570864619885724): 1083021641
Nearest node to (39.47079502801268, -0.37645893914044803): 524846232
Nearest node to (39.47244482795735, -0.37638903980168154): 1083021641
Nearest node to (39.472642128065104, -0.376668437172361): 7024437175
Nearest node to (39.47191672801728, -0.37677803614603894): 2262122052
Nearest node to (39.472734628027325, -0.3728258731748782): 25767442
Nearest node to (39.47336082804685, -0.37410326129277266): 57472035
Nearest node to (39.47267812807213, -0.37421326034372243): 57472073
Nearest node to (39.46571982782804, -0.37266987493743814): 6766690060
Nearest node to (39.46752162792522, -0.37949831057202404): 25767312
Nearest node to (39.472794227887086, -0.3759442439943026): 57472035
Nearest node to (39.4725893281084, -0.3773278309824072): 2938257929
Nearest node to (39.46982632800445, -0.37111468941075615): 25767434
Nearest node to (39.47237222798337, -0.3750137527081152): 57472073
Nearest node to (39.46850632804824, -0.3697768018495588): 25767431
Nearest node to (39.46837552807631, -0.37230147835198635): 25767431
Nearest node to (39.46711472806242, -0.3700633996129442): 25767431
Nearest node to (39.47384022804665, -0.37870991795741377): 2938257929
Nearest node to (39.46868072796615, -0.3793189120781753): 25767312
Nearest node to (39.46645402813667, -0.38104279589243495): 25767312
Nearest node to (39.467347528054276, -0.38115629501734616): 25767312
Nearest node to (39.470140228016604, -0.37533294974929887): 25767326
Nearest node to (39.471715228056105, -0.37635294013934495): 11918521212
Nearest node to (39.46964842799652, -0.37206208037580135): 25767434
Nearest node to (39.468597527985644, -0.3729288724251001): 25767431
Nearest node to (39.47297262811847, -0.3770217338299363): 7024437175
Nearest node to (39.47234132797064, -0.3777954265071431): 593995484
Nearest node to (39.47003062802424, -0.3776056283911947): 31781824
Nearest node to (39.47032352801973, -0.3778946257068236): 525306342
Nearest node to (39.46992492800584, -0.37797452485081434): 31781824
Nearest node to (39.47105822799483, -0.37813932326925476): 569810134
Nearest node to (39.47202342801354, -0.3707471927550443): 25767442
Nearest node to (39.47067782801437, -0.3774317299887715): 33358133
Nearest node to (39.47021412802378, -0.3768766352189619): 8205346061
Nearest node to (39.47463142789738, -0.3757020462801039): 1083021641
Nearest node to (39.47346312811373, -0.3762729408905937): 1083021641
Nearest node to (39.47321792796758, -0.37652913847749425): 7024437175
Nearest node to (39.46842092803604, -0.3737127650438157): 25767431
Nearest node to (39.4660945279213, -0.37774012709188703): 13173452314
Nearest node to (39.474120928076886, -0.37344896741008143): 57472035
Nearest node to (39.469272128032486, -0.3783969208662102): 25767312
Nearest node to (39.47282632795366, -0.37556924753349685): 57472035
Nearest node to (39.470933328053086, -0.37752222917324246): 33358133
Nearest node to (39.46884812803904, -0.3731576701872073): 25767431
Nearest node to (39.472017728059335, -0.37958130964760545): 569810146
Nearest node to (39.472371928070416, -0.37780142655953525): 593995484
Nearest node to (39.464461427921826, -0.37425485987370644): 7021924264
Nearest node to (39.474137328142625, -0.37682563568501576): 7024437175
Nearest node to (39.47408012812252, -0.3768453354924818): 7024437175
Nearest node to (39.468972928009805, -0.3737239648638583): 5574601914
Nearest node to (39.46775092812857, -0.3750916520082395): 25767292
Nearest node to (39.47091532804957, -0.3751208517250622): 55597231
Nearest node to (39.47068362800656, -0.37903681483147733): 569810130
Nearest node to (39.473385528116445, -0.374245259928757): 57472035
Nearest node to (39.46771702804825, -0.3751875511183451): 25767292
Nearest node to (39.46823462808677, -0.3779892247448184): 6088839481
Nearest node to (39.47200342805431, -0.37810222373516544): 593995484
Nearest node to (39.47326372807927, -0.37660753773858513): 7024437175
Nearest node to (39.47146962802602, -0.37726803148445764): 5005646482
Nearest node to (39.47208702805276, -0.3746876557715983): 57472073
Nearest node to (39.471935628027666, -0.37479355479542664): 57472073
Nearest node to (39.470747128007794, -0.3749990529009099): 55597231
Nearest node to (39.46847942808096, -0.3773122310718644): 7612237925
Nearest node to (39.47281322800247, -0.37613484219969545): 1083021641
Nearest node to (39.471329127987424, -0.3791968134510406): 593993789
Nearest node to (39.47120712800827, -0.37910381428813894): 593993789
Nearest node to (39.472840127969754, -0.37382536389179183): 57472073
Nearest node to (39.470359828019284, -0.38078779843970106): 593993799
Nearest node to (39.47012232801758, -0.37161128466792814): 25767434
Nearest node to (39.46623162807538, -0.37201138086183455): 6766690060
Nearest node to (39.46558692806341, -0.37960000957653767): 13173452314
Nearest node to (39.468673727987124, -0.37864611859813363): 25767312
Nearest node to (39.47358612807074, -0.3796761088435845): 593995484
Nearest node to (39.46884162804894, -0.37967980890193265): 25767312
Nearest node to (39.465444328165106, -0.37093709112358236): 6766690060
Nearest node to (39.472557427983794, -0.3775101292498624): 2938257929
Nearest node to (39.472553628014325, -0.37764442796616804): 2938257929
Nearest node to (39.47253682806465, -0.37778522669583253): 2938257929
Nearest node to (39.4649881281341, -0.3753483495886562): 7021924264
Nearest node to (39.47072402800998, -0.3777504269821897): 7024440285
Nearest node to (39.47386532800018, -0.37569514634137124): 1083021641
Nearest node to (39.47459212804382, -0.38069759923181007): 2938257929
Nearest node to (39.47400582797177, -0.3778419261753927): 7024437175
Nearest node to (39.46560052786951, -0.36900000902376445): 25767431
Nearest node to (39.46927772801571, -0.3741713606635609): 5574602442
Nearest node to (39.46703332809567, -0.38193018772233905): 25767312
Nearest node to (39.46719032810398, -0.3744289582605163): 6766690060
Nearest node to (39.467556028128485, -0.37430685945978404): 6766690060
Nearest node to (39.46799642791965, -0.374444458092067): 6766690060
Nearest node to (39.468862627986034, -0.3741086612184073): 5574601889
Nearest node to (39.47006262801932, -0.3710952895364707): 25767434
Nearest node to (39.47119402799008, -0.3792718126303633): 593993789
Nearest node to (39.47077092803701, -0.37357136636099125): 35594393
Nearest node to (39.471653528001625, -0.3708154922838167): 25767442
Nearest node to (39.466597528041845, -0.3687187122354057): 25767431
Nearest node to (39.472724027886784, -0.3767583363385729): 7024437175
Nearest node to (39.47091722803431, -0.37213467991608185): 35594393
Info: Snapped 395 POIs to graph nodes (ATTR_ONLY)
osmnx_snap_pois -> {'FINISHED'}
Nearest node to (39.4710110000001, -0.3818601998389715): 599162819
Nearest node to (39.470904500000245, -0.3814089999156251): 29935882
Nearest node to (39.4711825999934, -0.3803980998796771): 230343622
Nearest node to (39.47096749999998, -0.38087909984028334): 29935880
Nearest node to (39.47101380000009, -0.38067330008162625): 30764010
Nearest node to (39.47089979999877, -0.3812193000932114): 29935882
Nearest node to (39.470892000000596, -0.38308449980619247): 228619291
Nearest node to (39.47235220000288, -0.3811459999176754): 599162803
Nearest node to (39.471504600000436, -0.38129249992841463): 29935854
Nearest node to (39.47482529989835, -0.38223449983394125): 30378534
Nearest node to (39.474260099868395, -0.38122749987743565): 600116619
Nearest node to (39.47373220000431, -0.3818643998978173): 7688590788
Nearest node to (39.47451600012608, -0.38242439998982225): 603997195
Nearest node to (39.474382799912455, -0.3812150000412312): 30378529
Nearest node to (39.47425920012954, -0.3808255000482281): 9860046958
Nearest node to (39.47333989992548, -0.3811324001569919): 30036976
Nearest node to (39.47302809994054, -0.38125639997648536): 29935885
Nearest node to (39.47188389999855, -0.37926379998304244): 33214674
Nearest node to (39.47486890011234, -0.37495130007276833): 9336786277
Nearest node to (39.47393839994183, -0.37603249996942095): 59766863
Nearest node to (39.47382630003832, -0.37625680002034617): 59766863
Nearest node to (39.468155999956366, -0.3802543000307667): 6820736644
Nearest node to (39.47382499987948, -0.3760297999811913): 59766863
Nearest node to (39.4714707999916, -0.37766489999804853): 5005646482
Nearest node to (39.47330279996961, -0.3761282999890043): 7024437153
Nearest node to (39.47298800005108, -0.37550300004021625): 57472006
Nearest node to (39.47523919987419, -0.3817482998831447): 604972791
Nearest node to (39.468243699971254, -0.3759486000008344): 25767445
Nearest node to (39.471007799998915, -0.3751976999952031): 55597231
Nearest node to (39.47251100002495, -0.3764675999999237): 55597219
Nearest node to (39.470982400000516, -0.3724328998960958): 25767437
Nearest node to (39.47400470000168, -0.3750782000403596): 60707931
Nearest node to (39.47389849999434, -0.37343110010957037): 60707887
Nearest node to (39.46772639997969, -0.3790905000762756): 12478628313
Nearest node to (39.4685316000565, -0.38126779988821075): 12478593198
Nearest node to (39.467417899975295, -0.377791900002174): 7108153400
Nearest node to (39.467777199980674, -0.3787044000234434): 33358136
Nearest node to (39.46856999990319, -0.3767461000187099): 5574601866
Nearest node to (39.46889529999127, -0.3755534000519687): 25767449
Nearest node to (39.46712409999387, -0.37462649997774167): 12110757125
Nearest node to (39.46709659993266, -0.37466669999538227): 12110757125
Nearest node to (39.46848170006239, -0.3726522001166987): 25767367
Nearest node to (39.47471120003912, -0.3798073999858014): 30378592
Nearest node to (39.468556300126096, -0.3829272998356691): 30886269
Nearest node to (39.46907940004285, -0.3776138999840895): 25767316
Nearest node to (39.47440550002633, -0.37759609999096105): 7590173116
Nearest node to (39.47499850003044, -0.3787014000558104): 60191902
Nearest node to (39.47213820005044, -0.375175299970691): 57472040
Nearest node to (39.475018800036636, -0.3821421999212624): 30378550
Nearest node to (39.47441299999429, -0.3830423999189971): 7191464758
Nearest node to (39.47479839993107, -0.37703900000365403): 7574937326
Nearest node to (39.47491630002779, -0.3790513999831141): 7560985700
Nearest node to (39.47159360000664, -0.3755059000147148): 7580652363
Nearest node to (39.47158979997017, -0.375262700046182): 57472073
Nearest node to (39.471505899991755, -0.37517170004425116): 55597254
Nearest node to (39.47221730001451, -0.37505119998446385): 57472040
Nearest node to (39.47460510000276, -0.3770666000020816): 7574937329
Nearest node to (39.464871900058895, -0.3722834000913227): 6868146248
Nearest node to (39.475159100066264, -0.37770320001593494): 60191921
Nearest node to (39.47040449999336, -0.3724851001314669): 25767456
Nearest node to (39.47048940001663, -0.37504629997367683): 11918521076
Nearest node to (39.47304699995093, -0.375975699963065): 7024437160
Nearest node to (39.474602100069184, -0.3758621000371527): 7573207979
Nearest node to (39.472086900060525, -0.37659719999914437): 6761263365
Nearest node to (39.47067639999731, -0.37608500001059625): 524846227
Nearest node to (39.47386320005222, -0.374174600102454): 11927083826
Nearest node to (39.47421569988653, -0.3776199000061552): 59836756
Nearest node to (39.4732073000738, -0.37736540001053204): 9860001586
Nearest node to (39.47278699994484, -0.376499100007269): 7024437154
Nearest node to (39.47173070002671, -0.37653700000921775): 11918521212
Nearest node to (39.46953269997724, -0.3716593998796834): 1828089465
Nearest node to (39.4718995000284, -0.3710505996660774): 12173610396
Nearest node to (39.47137819999139, -0.3720750001639712): 55597271
Nearest node to (39.47050219998786, -0.37472160004538385): 55597212
Nearest node to (39.467273600034225, -0.3751571000050246): 5574601881
Nearest node to (39.464724300003276, -0.37338769996442867): 12384869090
Nearest node to (39.4747539999492, -0.3774723000058372): 59836769
Nearest node to (39.46824540001402, -0.37613499996301125): 25767445
Nearest node to (39.46836129988701, -0.3763046000370034): 207515787
Nearest node to (39.468352300086266, -0.3765017999954986): 207515787
Nearest node to (39.468361800009944, -0.37610949997773163): 25767445
Nearest node to (39.46814889987234, -0.3812753000676921): 10944088202
Nearest node to (39.47424409995461, -0.37808029999226883): 2260735672
Nearest node to (39.466492200020134, -0.3802680001317831): 11112343797
Nearest node to (39.473124700053205, -0.3790623999223016): 11875159845
Nearest node to (39.47399150007951, -0.37823199999319845): 7560985696
Nearest node to (39.46974830000148, -0.37379610004863845): 25767434
Nearest node to (39.46824589986894, -0.3755872999813406): 25767445
Nearest node to (39.47254240002662, -0.37090000028402365): 11950769844
Nearest node to (39.47314389997655, -0.37252829994314135): 6844144023
Nearest node to (39.47256979998283, -0.371231699731729): 11950769837
Nearest node to (39.47382140012, -0.37516599991478927): 8829277400
Nearest node to (39.47256170005495, -0.376234399995834): 57472031
Nearest node to (39.46642580025732, -0.3798060000819189): 34366709
Nearest node to (39.47498679994105, -0.3696125997881801): 60707994
Nearest node to (39.46973589998119, -0.3745084999199151): 5574602441
Nearest node to (39.47395560004346, -0.37385070011923666): 60682645
Nearest node to (39.46858400012927, -0.3715275999013052): 25767365
Nearest node to (39.47511139996984, -0.376684200015298): 90693924
Nearest node to (39.47495469987447, -0.37670260001063305): 90693921
Nearest node to (39.474657199894565, -0.37752700000447015): 59836764
Nearest node to (39.4709509999999, -0.3829130001230443): 29935845
Nearest node to (39.46914069994538, -0.3776060000056695): 25767316
Nearest node to (39.470616900008025, -0.3778051000073187): 7024440285
Nearest node to (39.46701210006134, -0.3793270999771363): 34366709
Nearest node to (39.467600799973034, -0.3788736000380981): 7107928808
Nearest node to (39.46896990005497, -0.37941920007714547): 7024440298
Nearest node to (39.468755600055566, -0.37858089999602307): 525252729
Nearest node to (39.47487229992986, -0.3757717999871631): 7024437130
Nearest node to (39.47467970006647, -0.3770513999986233): 7574937329
Nearest node to (39.47503270002372, -0.3796001999760634): 7560985701
Nearest node to (39.471761799981415, -0.376986599999013): 7024437179
Nearest node to (39.47374819991809, -0.37676090000080553): 11925149588
Nearest node to (39.474060199979014, -0.37692670001018963): 59836753
Nearest node to (39.46931460000837, -0.3780354000369098): 525306338
Nearest node to (39.47103149999852, -0.37784410002054614): 7310489657
Nearest node to (39.471649500001924, -0.37734210000441): 33214686
Nearest node to (39.47138690001319, -0.37695469999743): 1083021634
Nearest node to (39.465807999944616, -0.3825600000545084): 7313680611
Nearest node to (39.46693209995638, -0.38097280000404304): 7024440320
Nearest node to (39.466289600110116, -0.38112840008441506): 6232377132
Nearest node to (39.47148080000472, -0.3795366000025661): 8050387755
Nearest node to (39.46742680007307, -0.3805830000316366): 7024440314
Nearest node to (39.467758999901186, -0.379951000022042): 6820736648
Nearest node to (39.474905200032325, -0.3776953999872496): 59836766
Nearest node to (39.46483619996482, -0.372096500076674): 6868146248
Nearest node to (39.473422999935, -0.3771264999990424): 59766865
Nearest node to (39.470009200014445, -0.379420399994759): 593993796
Nearest node to (39.473721000037855, -0.37485729992960526): 8829277401
Nearest node to (39.47364160002682, -0.37714079999986894): 59766864
Nearest node to (39.46540210005968, -0.36976469976164755): 7506786564
Nearest node to (39.46821699994594, -0.3744903999473832): 6766690060
Nearest node to (39.469443699937536, -0.37374620000255837): 5574602442
Nearest node to (39.469199000048334, -0.37514650003837496): 9853663631
Nearest node to (39.4682325000048, -0.37657250000234027): 207515787
Nearest node to (39.46810130001492, -0.37680610000066767): 5574601865
Nearest node to (39.4683477000809, -0.3765472999964641): 207515787
Nearest node to (39.46835379991904, -0.37519129991379996): 25767292
Nearest node to (39.46884679999297, -0.37396330002700473): 5574601889
Nearest node to (39.468240600066686, -0.3762846000213176): 207515787
Nearest node to (39.46822979998417, -0.3764978000097214): 207515787
Nearest node to (39.46822200010326, -0.3746013999649999): 6766690060
Nearest node to (39.46824289993536, -0.37622099996102065): 207515787
Nearest node to (39.46867989990901, -0.3771439999998832): 7612237925
Nearest node to (39.47038440002963, -0.3771667000004081): 7511866448
Nearest node to (39.4741195998968, -0.3777624999808518): 2260735673
Nearest node to (39.47522409996728, -0.3762105999806399): 90693970
Nearest node to (39.46908609997492, -0.3770898999973537): 5574601870
Nearest node to (39.47094599999962, -0.377061099999938): 524846147
Nearest node to (39.4742839999021, -0.37758450000616717): 7590173116
Nearest node to (39.47282669995035, -0.3775278999860801): 9860001588
Nearest node to (39.474487099935054, -0.3777630999830584): 11006263977
Nearest node to (39.47527229991862, -0.37602629996108633): 79451175
Nearest node to (39.47518549991061, -0.37576829996705813): 79451175
Nearest node to (39.47252380006319, -0.37670270000376754): 7024437162
Nearest node to (39.47140139999369, -0.37771249998503703): 5005646483
Nearest node to (39.47103980000237, -0.37579329998666544): 11918521185
Nearest node to (39.47104000000297, -0.3759248999787747): 6766677107
Nearest node to (39.4693658000273, -0.3775778999818949): 525252743
Nearest node to (39.46902900005981, -0.3778300999835262): 25767316
Nearest node to (39.47136859999622, -0.37631189996981707): 1083021628
Nearest node to (39.4725780000157, -0.37037259968984504): 7590344507
Nearest node to (39.46761029989672, -0.37582499998027963): 7108153402
Nearest node to (39.46771690005601, -0.37580479997832483): 7108153402
Nearest node to (39.474683899919874, -0.38135889988327604): 604972727
Nearest node to (39.47485890006572, -0.3812140001098866): 604972728
Nearest node to (39.47513320007684, -0.3776041999990498): 60191921
Nearest node to (39.47510010003241, -0.37637800003207467): 90693970
Nearest node to (39.47446530009608, -0.3750358000001616): 7024437136
Nearest node to (39.471711300027394, -0.3753598999696479): 57472073
Nearest node to (39.47288880002278, -0.3788643999753309): 691122221
Nearest node to (39.47440260006373, -0.3750482000168308): 7024437136
Nearest node to (39.46974419998504, -0.3743965999640882): 5574602441
Nearest node to (39.467100999996056, -0.3821524999085087): 30886273
Nearest node to (39.46995080000751, -0.38131689998921514): 221281940
Nearest node to (39.475056700028404, -0.38183090011458237): 30378550
Nearest node to (39.47268069996652, -0.38061690013460775): 29935866
Nearest node to (39.47492969989193, -0.37058119995066385): 7599871367
Nearest node to (39.46908759994171, -0.3794234999555265): 569810139
Nearest node to (39.46977659996456, -0.3794818999388334): 6997447764
Nearest node to (39.47383309994137, -0.37846859998085874): 9755852641
Nearest node to (39.47397770006341, -0.3710080998063442): 60682657
Nearest node to (39.467246700066944, -0.3693731002600192): 7506483933
Nearest node to (39.47393980007165, -0.3770675999985259): 59836770
Nearest node to (39.470850900003455, -0.37727489999597336): 33358133
Nearest node to (39.468210300013865, -0.374442599930726): 6766690060
Nearest node to (39.4724296000582, -0.3736473998417427): 7580073984
Nearest node to (39.47305289998112, -0.37737919999618336): 9860001586
Nearest node to (39.47276730003256, -0.37616379998212685): 57472029
Nearest node to (39.472670800024886, -0.3760828999811734): 57472029
Nearest node to (39.47272179996784, -0.3763084000365117): 57472029
Nearest node to (39.47285159996191, -0.3784623999725242): 33291677
Nearest node to (39.47169790002924, -0.37172600016801216): 55597020
Nearest node to (39.47472249997655, -0.37500049999330803): 9336786277
Nearest node to (39.47157819998577, -0.37742360000757): 5005646482
Nearest node to (39.471048200002336, -0.37506310003546073): 55597213
Nearest node to (39.46728709986935, -0.3784838999720265): 7107928805
Nearest node to (39.47038519999852, -0.3783494999985531): 569810130
Nearest node to (39.468764000030404, -0.3741952000769466): 5574601889
Nearest node to (39.468869599943815, -0.3744579999150282): 5574602444
Nearest node to (39.46888619995153, -0.3743587999552739): 5574602444
Nearest node to (39.46775950002412, -0.37602810001110587): 7108153401
Nearest node to (39.46790550000796, -0.37597490001798933): 25767445
Nearest node to (39.46785239987028, -0.37578609996018664): 7108153402
Nearest node to (39.46814000004258, -0.3759534000184869): 25767445
Nearest node to (39.4733686998775, -0.3769584000038038): 59766865
Nearest node to (39.475191300103816, -0.3757837000381599): 79451175
Nearest node to (39.47274719996833, -0.3776230000103225): 691122226
Nearest node to (39.47464509992123, -0.3750039999266134): 9336786277
Nearest node to (39.46880560002066, -0.373754400133981): 25767431
Nearest node to (39.46620959973714, -0.37435149993566064): 7012538943
Nearest node to (39.46609900020245, -0.3745637999424545): 5896977835
Nearest node to (39.46564759985076, -0.3707523000084725): 208232436
Nearest node to (39.46710479996553, -0.37309939996746827): 52748278
Nearest node to (39.46953409997305, -0.3798044000181683): 569810116
Nearest node to (39.47411370000062, -0.37721139999933556): 59836754
Nearest node to (39.46974820003049, -0.3743142000661178): 25767434
Nearest node to (39.47497510011968, -0.37599179998610804): 86871066
Nearest node to (39.47418889989023, -0.3750365999452373): 11944782112
Nearest node to (39.47228129993767, -0.37252909988821703): 55597010
Nearest node to (39.47254959994762, -0.3710723002594458): 11950769844
Nearest node to (39.473560599944, -0.373634400039866): 8829277403
Nearest node to (39.466909400110524, -0.3697064002922727): 7506483933
Nearest node to (39.47323310009224, -0.37804170003837884): 33291683
Nearest node to (39.467294999989264, -0.37785959999798285): 7108153400
Nearest node to (39.466669899918585, -0.3781182999873521): 7619804342
Nearest node to (39.47189450000509, -0.37740060000255127): 593995484
Nearest node to (39.46787370012236, -0.3816691001126754): 30886274
Nearest node to (39.46939430006636, -0.3749009000610159): 524846196
Nearest node to (39.47507610002772, -0.3797285000211363): 7560985701
Nearest node to (39.47410890005329, -0.37797549998298674): 2260735673
Nearest node to (39.472170799971934, -0.3737792001673193): 7580073984
Nearest node to (39.466415999884646, -0.37904709993113384): 34366714
Nearest node to (39.475270499904866, -0.3730015000920612): 60707893
Nearest node to (39.4736032998831, -0.3756552999999526): 55597209
Nearest node to (39.472492199985545, -0.37804980000306776): 1771271225
Nearest node to (39.47382160006197, -0.37385849993092307): 60682645
Nearest node to (39.47377080006098, -0.3732175998379646): 60682646
Nearest node to (39.473986200009236, -0.3741448000651942): 11927083826
Nearest node to (39.47200120000136, -0.37377040007708995): 7580117486
Nearest node to (39.471862800023466, -0.3716588999140111): 55597020
Nearest node to (39.470315499987144, -0.37281020003229776): 35594396
Nearest node to (39.473945300083884, -0.37065199995063997): 60682657
Nearest node to (39.4712285999968, -0.37811120004080734): 297664558
Nearest node to (39.4725337999758, -0.3772928000039362): 7310489649
Nearest node to (39.47074820000239, -0.377420100009165): 33358133
Nearest node to (39.47133520000533, -0.3750720999383598): 11918521314
Nearest node to (39.468968700001135, -0.3753789000539891): 2213108120
Nearest node to (39.47038829997009, -0.37071689966128607): 7783685957
Nearest node to (39.47473130010334, -0.379814999984818): 30378592
Nearest node to (39.47170939997565, -0.37346979988299567): 7108214143
Nearest node to (39.46836129988701, -0.3761739999762387): 25767445
Nearest node to (39.47232080000122, -0.378435599989697): 1977731866
Nearest node to (39.46574350016654, -0.3711211996797846): 208232455
Nearest node to (39.47278319997536, -0.378006999990332): 7596154020
Nearest node to (39.47345819990614, -0.3757398000141455): 7024437156
Nearest node to (39.47274859996414, -0.37668019998612096): 7024437162
Nearest node to (39.47474160006292, -0.3790562000007666): 60146945
Nearest node to (39.47090330000085, -0.3779446000212478): 569810134
Nearest node to (39.46863330002945, -0.3758702000018417): 35594381
Nearest node to (39.470160400030565, -0.3792883000369774): 593993796
Nearest node to (39.46738659994461, -0.3696083000833983): 7506483933
Nearest node to (39.47168599999788, -0.3827189999014521): 599162794
Nearest node to (39.47341109990364, -0.37734199998957557): 9860001589
Nearest node to (39.46493610019204, -0.378956700061609): 2914614038
Nearest node to (39.47322259992268, -0.3763927999707706): 59766870
Nearest node to (39.47407429990806, -0.37687340000223873): 59836753
Nearest node to (39.47073129999797, -0.37612959998655193): 524846227
Nearest node to (39.475191099893834, -0.37715059999999734): 90693915
Nearest node to (39.47503479995042, -0.3801190999386184): 7003280574
Nearest node to (39.46687180003171, -0.3698414000509537): 7506483928
Nearest node to (39.47361959997786, -0.3741911000112353): 8829277402
Nearest node to (39.473522699952234, -0.37684810000322805): 11925149605
Nearest node to (39.47287289994597, -0.37153980010530463): 11950769848
Nearest node to (39.47223020002373, -0.3731608999115427): 7580117485
Nearest node to (39.47318459995993, -0.37733420000429): 9860001586
Nearest node to (39.46735889994144, -0.3716132999199112): 52748282
Nearest node to (39.46937109996356, -0.3780166000256371): 525306338
Nearest node to (39.472723199963646, -0.377176799998673): 33291716
Nearest node to (39.47258359999893, -0.37641519999528267): 55597219
Nearest node to (39.466333499969046, -0.3697994001568928): 7506483927
Nearest node to (39.47109509999586, -0.3763761999820552): 11918521202
Nearest node to (39.47039810000774, -0.3784303000063721): 569810130
Nearest node to (39.470221400020144, -0.37843780001225436): 7310521744
Nearest node to (39.47493519990417, -0.37767439999681934): 60191921
Nearest node to (39.473596799893, -0.37491729997666273): 8829277401
Nearest node to (39.4696554000213, -0.37253670006083284): 1828089466
Nearest node to (39.47124829999283, -0.3801384999954975): 229330233
Nearest node to (39.46816829987166, -0.37409970003676657): 5574601888
Nearest node to (39.46628980005209, -0.3816427998351217): 6789030579
Nearest node to (39.47230559998932, -0.3795588000408093): 593993806
Nearest node to (39.47175089999492, -0.37730070000405613): 593995483
Nearest node to (39.46769370008722, -0.3812410999853176): 30886274
Nearest node to (39.4681882999649, -0.37491880004727884): 6766690060
Nearest node to (39.46832210000443, -0.37442790007196414): 6766690061
Nearest node to (39.47098269999932, -0.3794906000359283): 593993798
Nearest node to (39.470804600003355, -0.37801099997610926): 593993788
Nearest node to (39.47484379989079, -0.37643499998129976): 7574937335
Nearest node to (39.47460239998214, -0.37652519999475514): 90693927
Nearest node to (39.47144989999199, -0.37727549999817994): 5005646482
Nearest node to (39.473099699936654, -0.37720560000151365): 9860001586
Nearest node to (39.47329700004441, -0.37748500000190977): 9860001589
Nearest node to (39.472571599996584, -0.3775946000071445): 5005646457
Nearest node to (39.47338950000663, -0.37364239983782127): 1287585236
Nearest node to (39.47401570002616, -0.37491979997862346): 60707931
Nearest node to (39.47333300005144, -0.37502980006489556): 4448994095
Nearest node to (39.466374699807346, -0.3734864001321098): 1764299335
Nearest node to (39.46817649990453, -0.380314900036631): 6820736644
Nearest node to (39.47344909986639, -0.37676080000767104): 11925149605
Nearest node to (39.473244200087706, -0.37814440001823807): 33291683
Nearest node to (39.47048119998376, -0.37193119996786245): 7580714263
Nearest node to (39.47302709996268, -0.37583029996360445): 7024437157
Nearest node to (39.46916120002755, -0.3705932998143304): 25767364
Nearest node to (39.469030400055615, -0.37311800007927165): 5574601914
Nearest node to (39.46776960004173, -0.37087990027520334): 52748285
Nearest node to (39.474495100025955, -0.3795265000015887): 60146953
Nearest node to (39.46933559994546, -0.38013549985426526): 597615424
Nearest node to (39.467108900115974, -0.38185939989389583): 30886273
Nearest node to (39.46800240003358, -0.38197290008707246): 30886272
Nearest node to (39.47079509999591, -0.3761495000091033): 524846227
Nearest node to (39.47237010003541, -0.37716949999940336): 7310489649
Nearest node to (39.470303299975825, -0.37287859984984845): 35594396
Nearest node to (39.46925239996495, -0.37374540005748264): 5574601914
Nearest node to (39.47362750009778, -0.3778382999847494): 7544463014
Nearest node to (39.47299619994995, -0.3786119999440309): 691122229
Nearest node to (39.47068550000355, -0.3784222000416832): 593993791
Nearest node to (39.47097839999904, -0.37871120007738435): 593993795
Nearest node to (39.470579799985146, -0.37879109997339405): 33214670
Nearest node to (39.47171309997414, -0.37895589994293405): 33214673
Nearest node to (39.47267829999285, -0.37156369985323445): 11950769838
Nearest node to (39.471332699993674, -0.3782483000025104): 297664558
Nearest node to (39.470869000003084, -0.37769320000809203): 7310489658
Nearest node to (39.475286299876686, -0.3765186000138827): 90693970
Nearest node to (39.474118000093036, -0.377089499997691): 59836770
Nearest node to (39.47387279994689, -0.37734569999594947): 59836758
Nearest node to (39.46907580001535, -0.3745293000542758): 5574602438
Nearest node to (39.46674939990061, -0.37855670000829106): 34366714
Nearest node to (39.47477580005619, -0.37426549993765124): 60707884
Nearest node to (39.46992700001179, -0.37921349996442444): 593993796
Nearest node to (39.47348119993297, -0.3763858000173603): 4558676313
Nearest node to (39.47158820003239, -0.37833880003876896): 33214684
Nearest node to (39.46950300001835, -0.3739741999730578): 5574602442
Nearest node to (39.47267260003864, -0.38039789989340816): 600116571
Nearest node to (39.47302680004972, -0.37861800005289614): 691122229
Nearest node to (39.46511629990113, -0.3750713999864186): 4286093905
Nearest node to (39.47479220012193, -0.37764219999413307): 59836766
Nearest node to (39.47473500010183, -0.3776618999870157): 59836763
Nearest node to (39.46962779998911, -0.37454049997973227): 5574602441
Nearest node to (39.468405800107874, -0.37590819999692493): 25767445
Nearest node to (39.47157020002888, -0.3759373999885784): 57472064
Nearest node to (39.47133849998587, -0.3798533999524391): 8050387756
Nearest node to (39.47404040009575, -0.37506179995111355): 60707931
Nearest node to (39.468371900027556, -0.3760041000096428): 25767445
Nearest node to (39.468889500066076, -0.37880580000575503): 2316852198
Nearest node to (39.472658300033615, -0.37891880005966017): 33214693
Nearest node to (39.473918600058575, -0.3774240999949422): 59836757
Nearest node to (39.47212450000533, -0.3780845999574495): 593995486
Nearest node to (39.47274190003207, -0.37550419995782974): 57472018
Nearest node to (39.47259050000697, -0.37561009997839057): 57472046
Nearest node to (39.4714019999871, -0.37581560001804304): 524846175
Nearest node to (39.46913430006027, -0.3781287999608673): 25767312
Nearest node to (39.47346809998178, -0.37695140000699373): 59766858
Nearest node to (39.47198399996673, -0.3800134000779258): 29935868
Nearest node to (39.471861999987574, -0.37992040003970645): 600116547
Nearest node to (39.47349499994906, -0.3746418999620439): 4448994095
Nearest node to (39.47101469999859, -0.3816044000410998): 599162810
Nearest node to (39.47077719999689, -0.37242779989903985): 25767456
Nearest node to (39.466886500054684, -0.37282789985869264): 52748278
Nearest node to (39.46624180004272, -0.38041659999834604): 11112343804
Nearest node to (39.46932859996643, -0.3794627000418225): 569810139
Nearest node to (39.47424100005005, -0.380492699981647): 196120260
Nearest node to (39.46949650002825, -0.38049640007482044): 597615414
Nearest node to (39.46609920014441, -0.37175360000911545): 7506786512
Nearest node to (39.4732122999631, -0.37832670000150315): 33291682
Nearest node to (39.47320849999363, -0.37846099998184213): 33291682
Nearest node to (39.473191700043955, -0.37860180003671867): 11875159839
Nearest node to (39.46564300011341, -0.3761648999934055): 7811022858
Nearest node to (39.47137889998929, -0.3785669999955374): 297664558
Nearest node to (39.47452019997949, -0.37651170001020706): 11939684615
Nearest node to (39.475247000023124, -0.38151419998424463): 604972785
Nearest node to (39.47466069995108, -0.3786585000499401): 5032898171
Nearest node to (39.46625539984882, -0.3698164996772825): 208232407
Nearest node to (39.46993259999502, -0.3749878999903699): 524846188
Nearest node to (39.467688200074974, -0.38274680007602263): 6789030498
Nearest node to (39.46784520008329, -0.3752455000118603): 25767292
Nearest node to (39.46821090010779, -0.37512340006192163): 25767292
Nearest node to (39.46865129989896, -0.375260999989297): 35594380
Nearest node to (39.46951749996534, -0.3749251999550828): 524846193
Nearest node to (39.47071749999863, -0.3719117999109834): 7580714266
Nearest node to (39.47184889996939, -0.38008839996314847): 29935868
Nearest node to (39.47142580001632, -0.37438790004059247): 25767442
Nearest node to (39.47230839998093, -0.37163200002484903): 55597055
Nearest node to (39.46725240002115, -0.3695352002413296): 7506483933
Nearest node to (39.47337889986609, -0.37757490001426186): 9860001589
Nearest node to (39.47157210001362, -0.37295120007344323): 55597005
Info: Snapped 395 POIs to graph nodes (ATTR_ONLY)
osmnx_snap_pois, walk network only -> {'FINISHED'} in 1.4 s (395 POIs x 1472 nodes, one query each)
the two runs disagree on 318 of 395 amenities, and neither reported anything unusual

snap distance to the nearest intersection, meters:
  p5       4.9
  p25      8.1
  p50     12.9
  p75     21.1
  p90     29.5
  p95     40.3
  p99     66.3
  p100    99.3
  mean 16.4   over 50 m: 9   over 80 m: 1

The tail is not noise. The furthest amenities are the ones the network filter cannot see: a walk download keeps footways and pedestrian streets but not the interior of a market hall or a private courtyard.

order = np.argsort(-nearest_m)[:5]
for i in order:
    row = pois.iloc[i]
    name = str(row.get("name"))
    print(f"  {nearest_m[i]:6.1f} m  {str(row.get('amenity')):<11} "
          f"{(name if name != 'nan' else '(unnamed)')[:34]}")
    99.3 m  restaurant  La Gallineta
    77.4 m  restaurant  Voltereta Manhattan
    75.5 m  restaurant  Restaurant mediterran
    71.2 m  restaurant  (unnamed)
    66.0 m  restaurant  Bai Wei Xuan

Snapping to a node is not snapping to the street

The distribution above is against intersections, because that is what a graph node is. The distance to the nearest street edge is a much smaller number, and the gap between the two is the length of a city block, not a data-quality problem. It is also why routing operators take an edge and accessibility operators take a node.

Measured on a projected copy, because ox.distance.nearest_edges returns distances in the graph’s own units (meters when projected, degrees when not) while nearest_nodes returns meters either way.

G_metric = ox.project_graph(G, to_crs=METRIC_CRS)
px, py = to_utm.transform(pois.geometry.x.values, pois.geometry.y.values)
_edges, edge_m = ox.distance.nearest_edges(G_metric, X=px, Y=py, return_dist=True)
edge_m = np.asarray(edge_m, dtype=float)
print(f"to the nearest node : median {np.median(nearest_m):5.1f} m  "
      f"p95 {np.percentile(nearest_m, 95):5.1f} m")
print(f"to the nearest edge : median {np.median(edge_m):5.1f} m  "
      f"p95 {np.percentile(edge_m, 95):5.1f} m")
print(f"ratio at the median : {np.median(nearest_m) / max(np.median(edge_m), 1e-9):.1f}x")
to the nearest node : median  12.9 m  p95  40.3 m
to the nearest edge : median   5.1 m  p95  15.4 m
ratio at the median : 2.6x

What the operator actually wrote

It stores the nearest node id as an INT point attribute (features_operators.py:696), and Blender’s INT attribute is 32-bit. Modern OpenStreetMap node ids are not: they passed 2³¹ years ago, and 746 of the 1472 intersections in this graph are above it. The assignment at features_operators.py:719 is wrapped in except (TypeError, ValueError): nattr.data[i].value = -1 (:720-721), so every POI whose nearest intersection has a modern id is silently recorded as unsnapped and the operator reports success for all of them.

written = by_walk
too_big = nearest_id > 2 ** 31 - 1
missing = written == -1
print(f"graph node ids run {int(min(G.nodes))}{int(max(G.nodes))}; "
      f"the int32 ceiling is {2 ** 31 - 1}")
print(f"intersections above the int32 ceiling: "
      f"{sum(1 for n in G.nodes if n > 2 ** 31 - 1)} of {G.number_of_nodes()}")
print(f"POIs recorded as -1: {int(missing.sum())} of {len(written)} "
      f"({100 * missing.mean():.1f}%)")
print(f"  of which the true nearest node is above the ceiling: "
      f"{int((missing & too_big).sum())}")
print(f"POIs recorded with an id: {int((~missing).sum())}, agreeing with "
      f"ox.distance.nearest_nodes on {int((written[~missing] == nearest_id[~missing]).sum())}")

check("every -1 is an int32 overflow, not a failed query",
      bool((missing == too_big).all()),
      f"{int(missing.sum())} POIs lost to a 32-bit attribute")
check("the ids it does write are correct",
      bool((written[~missing] == nearest_id[~missing]).all()),
      "so the geometry and the query are fine; only the storage is not")
graph node ids run 25767290 … 13970549071; the int32 ceiling is 2147483647
intersections above the int32 ceiling: 746 of 1472
POIs recorded as -1: 170 of 395 (43.0%)
  of which the true nearest node is above the ceiling: 170
POIs recorded with an id: 225, agreeing with ox.distance.nearest_nodes on 225
[PASS] every -1 is an int32 overflow, not a failed query — 170 POIs lost to a 32-bit attribute
[PASS] the ids it does write are correct — so the geometry and the query are fine; only the storage is not
True

The coordinate round trip, at least, is exact. blender_to_lonlat (features_operators.py:684-687) inverts the same equirectangular projection _convert_osmnx_coords_to_3d used to build the mesh, from the same origin (get_graph_extent’s mean of the node coordinates), so a POI comes back within a hundredth of a millimeter of where it started. The ADD_CONNECTOR and MOVE_TO_NODE modes read node_positions through int(nid) in plain Python and are unaffected by the ceiling; only the recorded attribute is lost.

mpd = math.pi / 180.0 * 6371000.0
cos_lat = math.cos(math.radians(lat0))
scale = obj_net.get("osmnx_scale", 0.001)
back_lat = np.array([lat0 + v.co.y / scale / mpd for v in obj_pois.data.vertices])
back_lon = np.array([lon0 + v.co.x / scale / (mpd * cos_lat)
                    for v in obj_pois.data.vertices])
_a, _b, round_trip = GEOD.inv(pois.geometry.x.values, pois.geometry.y.values,
                              back_lon, back_lat)
print(f"round-trip position error: median {np.median(round_trip) * 1000:.3f} mm, "
      f"max {round_trip.max() * 1000:.3f} mm")
round-trip position error: median 0.007 mm, max 0.033 mm

The figure

One edge from each amenity to the intersection it was attached to, colored by how far that was, drawn over the street network in the flat neutral. The top percentile is clipped: a handful of 70 to 100 m outliers would otherwise take the whole top of the ramp and press the ordinary 10 m snaps into the bottom stop.

anchor = sg.graphs.anchor(obj_net["osmnx_center_lat"], obj_net["osmnx_center_lon"],
                          scale=obj_net.get("osmnx_scale", 0.001),
                          name="Anchor_Accessibility")

snap_nodes = {}
for i, node_id in enumerate(nearest_id):
    snap_nodes[f"poi_{i}"] = (pois.geometry.iloc[i], float(nearest_m[i]))
    if node_id not in snap_nodes:
        snap_nodes[node_id] = (
            __import__("shapely").geometry.Point(G.nodes[node_id]["x"],
                                                 G.nodes[node_id]["y"]), 0.0)

snap_gdf = gpd.GeoDataFrame(
    {"snap_m": [v[1] for v in snap_nodes.values()]},
    geometry=[v[0] for v in snap_nodes.values()],
    index=list(snap_nodes.keys()), crs="EPSG:4326")
connector_gdf = gpd.GeoDataFrame(
    {"length_m": nearest_m},
    geometry=[LineString([pois.geometry.iloc[i],
              (G.nodes[nearest_id[i]]["x"], G.nodes[nearest_id[i]]["y"])])
              for i in range(len(pois))],
    index=pd.MultiIndex.from_tuples([(f"poi_{i}", nearest_id[i])
                                    for i in range(len(pois))]),
    crs="EPSG:4326")

obj_connect = sg.graphs.from_gdf(snap_gdf, connector_gdf, name="POI_Connectors",
                                 ref=anchor, coll="NB13_Accessibility",
                                 markers={"graph_type": "osmnx_snap_pois"})
LAYERS.append(obj_connect)
print(sg.graphs.summary(obj_connect))

path, numbers = overlay(obj_net, obj_connect, "node_snap_m",
                        f"renders/{RENDERS}/4_poi_snapping",
                        clip_high_pct=98)
nb.show(path)
nb.check_render(path)
report(numbers, "amenities and connectors", "street network")
{'object': 'POI_Connectors', 'type': 'MESH', 'num_nodes': 685, 'num_edges': 395, 'is_directed': False, 'vertices': 685, 'mesh_edges': 395, 'attributes': ['position', '.edge_verts', '.corner_vert', '.corner_edge', 'node_id', 'node_snap_m', 'edge_length_m'], 'graph_type': 'osmnx_snap_pois'}
  Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> node_snap_m  ·  node_snap_m -> turbo [log]  [0 … 43.59] on Point
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added

[PASS] 4_poi_snapping.png legible — 6.4% ink (healthy range 0.5–60%)
  amenities and connectors   median luminance 97, dimmest 5% at 45, 1.4% of the frame
  street network             median luminance 47
  separation                 +50 levels

5 · Distance on the network, not in the plane

osmnx_network_dbscan clusters the graph’s nodes with DBSCAN on a precomputed distance matrix built from all_pairs_dijkstra_path_length (accessibility.py:220-234). eps is a distance along the streets, so two points either side of a river, a railway or a walled block are far apart however close they look on a map.

The detour factor, below, is the ratio of a node’s shortest network distance to the center to its straight-line distance: 1.0 on a node you can walk to directly, unbounded on one you cannot.

nodes_metric = ox.graph_to_gdfs(G, edges=False).to_crs(METRIC_CRS)
xy = np.c_[nodes_metric.geometry.x.values, nodes_metric.geometry.y.values]
node_ids = list(G.nodes)
position = {n: i for i, n in enumerate(node_ids)}

t0 = time.time()
all_pairs = dict(nx.all_pairs_dijkstra_path_length(G, weight="length"))
network_d = np.full((len(node_ids), len(node_ids)), np.inf, dtype=np.float32)
for source, rest in all_pairs.items():
    i = position[source]
    for target, meters in rest.items():
        network_d[i, position[target]] = meters
# What the operator does (`accessibility.py:232`): the shorter of the two
# directions. A no-op on a pedestrian graph, an understatement on a one-way
# drive network.
network_d = np.minimum(network_d, network_d.T)
straight = np.sqrt(((xy[:, None, :] - xy[None, :, :]) ** 2).sum(-1))
print(f"all-pairs shortest paths on {len(node_ids)} nodes: {time.time() - t0:.1f} s "
      f"({network_d.nbytes / 1e6:.1f} MB, and it is O(n^2))")

center_i = position[center_node]
detour = np.where(straight[center_i] > 30.0,
                  network_d[center_i] / np.maximum(straight[center_i], 1e-9), 1.0)
print(f"\ndetour factor from the center: median {np.median(detour):.2f}, "
      f"p95 {np.percentile(detour, 95):.2f}, max {detour.max():.2f}")
all-pairs shortest paths on 1472 nodes: 5.3 s (8.7 MB, and it is O(n^2))

detour factor from the center: median 1.21, p95 1.44, max 2.05

Where the two metrics disagree most

Not at long range: over a kilometer the street grid averages out and the detour factor settles near 1.2. The disagreement is local, between pairs of nodes a few dozen meters apart on opposite sides of something you cannot cross.

window = (straight > 40.0) & (straight < 200.0) & np.isfinite(network_d)
ratio = np.where(window, network_d / np.maximum(straight, 1.0), 0.0)
print(f"pairs 40-200 m apart on the map: {int(window.sum()) // 2:,}")
print(f"  of which the walk is more than 5x the straight line: "
      f"{int((ratio > 5).sum()) // 2:,}")

nodes_wgs = ox.graph_to_gdfs(G, edges=False)
flat = np.argsort(-ratio, axis=None)[:200]
seen = set()
shown = 0
print(f"\n{'straight':>9}  {'on foot':>9}  {'ratio':>6}   node and where")
for k in flat:
    i, j = np.unravel_index(k, ratio.shape)
    if (j, i) in seen:
        continue
    seen.add((i, j))
    print(f"{straight[i, j]:>7.0f} m  {network_d[i, j]:>7.0f} m  "
          f"{ratio[i, j]:>6.1f}   {node_ids[i]} at "
          f"{nodes_wgs.geometry.iloc[i].y:.5f}, "
          f"{nodes_wgs.geometry.iloc[i].x:.5f}")
    shown += 1
    if shown >= 4:
        break

check("network distance and straight-line distance genuinely disagree",
      int((ratio > 5).sum()) // 2 > 100,
      "hundreds of pairs within 200 m need a detour of more than 5x")
pairs 40-200 m apart on the map: 92,552
  of which the walk is more than 5x the straight line: 375

 straight    on foot   ratio   node and where
     41 m      773 m    19.0   10896195456 at 39.46480, -0.37396
     44 m      794 m    18.1   10896195449 at 39.46489, -0.37374
     40 m      683 m    16.9   29834290 at 39.46796, -0.38312
     44 m      685 m    15.7   4443555083 at 39.46526, -0.37329
[PASS] network distance and straight-line distance genuinely disagree — hundreds of pairs within 200 m need a detour of more than 5x
True

The figure

The network colored by the detour factor from the center, interpolated along each street the way section 1 explains. Read against figure 1 it is the same map with the radial component divided out: dark blue is a street the center reaches as directly as a bird would, every warm run a place where the network makes you go round something.

The top percentile is clipped, and here it buys less than usual: a detour costing twenty times the straight line is a local fact about two nearby nodes, so from a single well-connected origin the factor stops at about 2. The pairwise view finds 375 pairs over 5x; this one finds a maximum of 2.

node_attribute(obj_net, {n: float(detour[position[n]]) for n in node_ids},
               "node_detour")
plain(obj_net)
nb.figure(obj_net, f"renders/{RENDERS}/5_detour",
          look='ink', color_attribute="node_detour",
          nodes_only=False, clip_high_pct=98,
          node_fraction=NODE_FRACTION,
          hide=[o for o in LAYERS if o is not obj_net])
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
Info: Attribute -> node_detour  ·  node_detour -> turbo [log]  [1 … 1.57] on Point

[PASS] 5_detour.png legible — 9.3% ink (healthy range 0.5–60%)

DBSCAN, both ways

The same algorithm, eps and min_samples on the same 1472 nodes, once with network distance and once with straight-line distance. The adjusted Rand index compares the two partitions: 1.0 is identical, 0.0 is chance.

from sklearn.cluster import DBSCAN
from sklearn.metrics import adjusted_rand_score

print(f"{'eps':>5}  {'network':>18}  {'straight line':>18}  {'ARI':>6}")
sweep = {}
for eps in (60, 80, 100, 120):
    labels = sg_access.network_dbscan(G, eps_meters=float(eps), min_samples=5,
                                      weight="length")
    net = np.array([labels[n] for n in node_ids])
    euclid = DBSCAN(eps=float(eps), min_samples=5).fit_predict(xy)
    sweep[eps] = (net, euclid)
    print(f"{eps:>5}  {len(set(net) - {-1}):>3} clusters, {int((net == -1).sum()):>3} "
          f"noise  {len(set(euclid) - {-1}):>3} clusters, "
          f"{int((euclid == -1).sum()):>3} noise  "
          f"{adjusted_rand_score(net, euclid):>6.3f}")

net_80, euclid_80 = sweep[80]
check("the two metrics find different structure",
      len(set(net_80) - {-1}) > len(set(euclid_80) - {-1}),
      "the network metric splits what the plane merges")
  eps             network       straight line     ARI
   60   15 clusters,  55 noise   13 clusters,  27 noise   0.960
   80    7 clusters,  20 noise    3 clusters,   7 noise   0.835
  100    3 clusters,   7 noise    1 clusters,   3 noise   0.073
  120    2 clusters,   4 noise    1 clusters,   3 noise   0.077
[PASS] the two metrics find different structure — the network metric splits what the plane merges
True

At 60 m the two agree almost completely: at that range a street is a straight line. As eps grows the plane starts jumping barriers the network cannot; by 100 m the straight-line version has merged the whole neighborhood into one cluster while the network version still holds it apart, and the ARI has collapsed.

What the operator writes onto the mesh

osmnx_network_dbscan also stores the labels as an INT point attribute. Cluster ids are small, so nothing overflows. What goes wrong is the other end: the attribute is created for every mesh vertex and written only for the first len(node_ids) of them (accessibility_operators.py:303-309). The street shape points keep Blender’s default of 0, which is not a sentinel but cluster 0, the largest one.

sg.graphs.activate(obj_net)
t0 = time.time()
print("osmnx_network_dbscan ->",
      bpy.ops.scigraphs.osmnx_network_dbscan(eps=80.0, min_samples=5),
      f"in {time.time() - t0:.1f} s")
print(f"  {obj_net['osmnx_dbscan_clusters']} clusters, "
      f"{obj_net['osmnx_dbscan_noise']} noise")

raw = obj_net.data.attributes["dbscan_cluster"]
labels_mesh = np.array([raw.data[i].value for i in range(len(obj_net.data.vertices))])
marker = obj_net.data.attributes["is_intersection"]
is_node = np.array([marker.data[i].value
                   for i in range(len(obj_net.data.vertices))]) != 0
print(f"  mesh vertices {len(labels_mesh)}, of which intersections "
      f"{int(is_node.sum())}")
print(f"  values on the {int((~is_node).sum())} street shape vertices: "
      f"{np.unique(labels_mesh[~is_node])}")
check("the shape vertices default into cluster 0",
      bool((labels_mesh[~is_node] == 0).all()) and 0 in set(net_80),
      f"{int((~is_node).sum())} vertices join the largest cluster silently")
Info: DBSCAN: 7 clusters, 20 noise points
osmnx_network_dbscan -> {'FINISHED'} in 4.9 s
  7 clusters, 20 noise
  mesh vertices 3444, of which intersections 1472
  values on the 1972 street shape vertices: [0]
[PASS] the shape vertices default into cluster 0 — 1972 vertices join the largest cluster silently
True

The figure needs the shape vertices filled honestly instead. A cluster id is not a quantity, so the mean of two labels is meaningless: a street between cluster 2 and cluster 5 is not in cluster 3.5. fill='endpoint' gives a street its cluster when both ends agree and marks it noise when they do not. A segment bridging two clusters belongs to neither.

cluster_of = {n: float(v) for n, v in
              sg_access.network_dbscan(G, eps_meters=80.0, min_samples=5,
              weight="length").items()}
written, filled = node_attribute(obj_net, cluster_of, "node_cluster",
                                 fill="endpoint", default=-1.0)
bridging = int((filled[~is_node] == -1).sum())
print(f"street vertices between two different clusters: {bridging} of "
      f"{int((~is_node).sum())}")

plain(obj_net)
nb.figure(obj_net, f"renders/{RENDERS}/6_network_clusters",
          look='ink', color_attribute="node_cluster",
          nodes_only=False, node_fraction=NODE_FRACTION,
          hide=[o for o in LAYERS if o is not obj_net])
street vertices between two different clusters: 127 of 1972
  Skipping Split Edges: 'is_intersection' present, keeping streets continuous
Info: Geometry Nodes modifier added
Info: Attribute -> node_cluster  ·  node_cluster -> turbo [log]  [-1 … 6] on Point

[PASS] 6_network_clusters.png legible — 7.1% ink (healthy range 0.5–60%)

6 · The same fifteen minutes, twice

Notebook 17 answers the same question with c2g.create_isochrone, on a graph built by c2g.gdf_to_nx from the same OSMnx download, with travel_time derived from length at the same 4.8 km/h. Two implementations sharing no code below networkx.

They are not given the same graph:

OSMnx path city2graph path (notebook 17)
graph the MultiDiGraph as downloaded rebuilt from two GeoDataFrames
direction directed undirected
parallel edges kept, cheapest wins in the weight function dropped: 05 deduplicates on (u, v) keeping the shortest
node identity OSM ids renumbered 0 to n-1 by gdf_to_nx
cost travel_time, imputed from length travel_time, computed from length
connectors = ox.graph_to_gdfs(G, edges=False).to_crs(METRIC_CRS)[["geometry"]]
connectors.index.name = "connector_id"
edges_ox = ox.graph_to_gdfs(G, nodes=False)
segments = (edges_ox.to_crs(METRIC_CRS).reset_index()
            .sort_values("length")
            .drop_duplicates(subset=["u", "v"])
            .set_index(["u", "v"])[["length", "geometry"]])
segments["travel_time"] = segments["length"] / SPEED_MS

parallel = edges_ox.reset_index().groupby(["u", "v"]).size()
undirected_pairs = {(min(u, v), max(u, v)) for u, v, _k in edges_ox.index}
print(f"osmnx directed edges          {len(edges_ox)}")
print(f"  (u, v) pairs with a parallel {int((parallel > 1).sum())}")
print(f"  distinct undirected pairs    {len(undirected_pairs)}")
print(f"c2g segments after 05's dedup  {len(segments)}")

G_c2g = c2g.gdf_to_nx(nodes=connectors, edges=segments)
print(f"c2g graph: {G_c2g.number_of_nodes()} nodes, "
      f"{G_c2g.number_of_edges()} edges, directed={G_c2g.is_directed()}")
osmnx directed edges          4396
  (u, v) pairs with a parallel 40
  distinct undirected pairs    2178
c2g segments after 05's dedup  4354
c2g graph: 1472 nodes, 2178 edges, directed=False

gdf_to_nx renumbers the nodes. The graph it returns is keyed 0 to n-1 in the order of the nodes GeoDataFrame, and the OSM id survives only as the _original_index node attribute. That is why notebook 05 passes create_isochrone a geometry rather than a node id, and the first thing to sort out before the two answers can be compared.

c2g_to_osm = {n: G_c2g.nodes[n].get("_original_index") for n in G_c2g.nodes}
check("the c2g renumbering is positional",
      all(c2g_to_osm[i] == connectors.index[i] for i in range(len(connectors))),
      f"node 0 is {c2g_to_osm[0]}")
center_c2g = next(k for k, v in c2g_to_osm.items() if v == center_node)
[PASS] the c2g renumbering is positional — node 0 is 25767290

The reachable sets

d_c2g = {c2g_to_osm[k]: v for k, v in
         nx.single_source_dijkstra_path_length(G_c2g, center_c2g,
         weight="travel_time").items()}
print(f"{'budget':>7}  {'osmnx':>7}  {'c2g':>7}  {'osmnx only':>11}  "
      f"{'c2g only':>9}  {'Jaccard':>8}")
for budget in (300, 600, 900):
    a = {n for n, s in seconds.items() if s <= budget}
    b = {n for n, s in d_c2g.items() if s <= budget}
    print(f"{budget:>6} s  {len(a):>7}  {len(b):>7}  {len(a - b):>11}  "
          f"{len(b - a):>9}  {len(a & b) / len(a | b):>8.4f}")

common = [n for n in seconds if n in d_c2g]
delta = np.array([seconds[n] - d_c2g[n] for n in common])
print(f"\nper-node cost difference, seconds:")
print(f"  largest absolute {np.abs(delta).max():.3e}")
print(f"  nodes differing by more than a millisecond: "
      f"{int((np.abs(delta) > 1e-3).sum())} of {len(delta)}")
print(f"  nodes differing at all (floating point): "
      f"{int((delta != 0).sum())}")

check("the two implementations agree on who is reachable",
      all({n for n, s in seconds.items() if s <= t}
      == {n for n, s in d_c2g.items() if s <= t}
      for t in (300, 600, 900)))
check("and on what it costs to get there",
      float(np.abs(delta).max()) < 1e-6,
      f"largest disagreement {np.abs(delta).max():.2e} s")
 budget    osmnx      c2g   osmnx only   c2g only   Jaccard
   300 s      332      332            0          0    1.0000
   600 s     1230     1230            0          0    1.0000
   900 s     1472     1472            0          0    1.0000

per-node cost difference, seconds:
  largest absolute 2.274e-13
  nodes differing by more than a millisecond: 0 of 1472
  nodes differing at all (floating point): 394
[PASS] the two implementations agree on who is reachable
[PASS] and on what it costs to get there — largest disagreement 2.27e-13 s
True

Why they agree, which is not obvious

Three of the five differences do not matter here:

  • Direction. OSMnx’s walk filter marks every way two-way, so the directed and undirected graphs have the same reachability. That would not survive a drive network; section 3 measured the asymmetry.
  • Parallel edges. 40 of 4354 (u, v) pairs carry more than one segment. The add-on’s weight function takes the cheapest (accessibility.py:104-112); notebook 17 sorts by length and keeps the shortest. Same edge either way.
  • Renumbering. Cosmetic, once mapped back.

What is left is the same Dijkstra over the same lengths at the same speed, and the residue is floating point: the two paths sum the same segments in different orders, nothing above a nanosecond. The reachable set is not where the two answers differ; the polygon is.

center_geom = connectors.geometry.loc[center_node]
hull_c2g = c2g.create_isochrone(graph=G_c2g, center_point=center_geom,
                                threshold=[300, 600, 900],
                                edge_attr="travel_time", method="convex_hull")
buffer_c2g = c2g.create_isochrone(graph=G_c2g, center_point=center_geom,
                                  threshold=[300, 600, 900],
                                  edge_attr="travel_time", method="buffer",
                                  buffer_distance=25.0)

print(f"{'':<22}{'5 min':>10}{'10 min':>10}{'15 min':>10}   (km2)")
rows = [
    ("convex hull, c2g", [a / 1e6 for a in hull_c2g.geometry.area]),
    ("convex hull, add-on",
     [transform(to_utm.transform, polygons[("CONVEX_HULL", m)]).area / 1e6
     for m in (5, 10, 15)]),
    ("buffer 25 m, c2g", [a / 1e6 for a in buffer_c2g.geometry.area]),
    ("buffer 25 m, add-on",
     [transform(to_utm.transform, polygons[("BUFFER_UNION", m)]).area / 1e6
     for m in (5, 10, 15)]),
]
for label, areas in rows:
    print(f"{label:<22}" + "".join(f"{a:>10.4f}" for a in areas))

shortfall = [100 * (1 - b / a) for a, b in zip(rows[2][1], rows[3][1])]
print("\nthe add-on's buffer polygon is smaller by: "
      + ", ".join(f"{s:.1f}%" for s in shortfall))

check("the two convex hulls are the same polygon",
      all(abs(a - b) / a < 1e-6 for a, b in zip(rows[0][1], rows[1][1])),
      "both are the hull of the same reachable node set")
check("the two buffer polygons are not",
      all(s > 3.0 for s in shortfall),
      "the add-on's is short by roughly the anisotropy of section 2")
                           5 min    10 min    15 min   (km2)
convex hull, c2g          0.3096    1.1800    1.3712
convex hull, add-on       0.3096    1.1800    1.3712
buffer 25 m, c2g          0.3135    1.0833    1.2521
buffer 25 m, add-on       0.2980    1.0206    1.1861

the add-on's buffer polygon is smaller by: 5.0%, 5.8%, 5.3%
[PASS] the two convex hulls are the same polygon — both are the hull of the same reachable node set
[PASS] the two buffer polygons are not — the add-on's is short by roughly the anisotropy of section 2
True

The disagreement, explained

The convex hulls are identical to six figures: two independent implementations given the same reachable node set compute the same hull of it.

The buffer polygons differ by about 5 %, in one direction, at every threshold. That is section 2’s two faults arriving in the answer:

  1. the buffer is 25 / 111000 degrees, which at this latitude is 25.0 m north to south and 19.4 m east to west, so the add-on’s polygon is narrower across than it should be everywhere;
  2. the buffered edges are straight lines between endpoints rather than street centerlines, so every bend loses a sliver.

Both push the same way, and neither is visible in anything the operator reports.

It also exposes a fault of notebook 17’s. Its 15-minute graph is a hairball because “within a 600 m radius almost any amenity reaches almost any other in 15 minutes”, which notebook 17 reads as a fact about the neighborhood. It is a fact about the download: the furthest intersection here is 858 s from the center, so a 900 s budget contains the whole of it, and would contain any 600 m download of anywhere.

print(f"eccentricity from the center : {reach.max():.0f} s")
print(f"the 15-minute budget         : {THRESHOLD_S} s")
print(f"a 900 s walk at {SPEED_KPH} km/h  : {THRESHOLD_S * SPEED_MS:.0f} m of network")
print(f"the download radius          : {RADIUS_M} m")
check("the 15-minute answer is bounded by the download, not the budget",
      reach.max() < THRESHOLD_S,
      "which is a property of the query, not of the city")
eccentricity from the center : 858 s
the 15-minute budget         : 900 s
a 900 s walk at 4.8 km/h  : 1200 m of network
the download radius          : 600 m
[PASS] the 15-minute answer is bounded by the download, not the budget — which is a property of the query, not of the city
True

7 · Saving

out_dir = nb.out(RENDERS)
out_dir.mkdir(parents=True, exist_ok=True)

isochrone_gdf = gpd.GeoDataFrame(
    {"minutes": [5, 10, 15],
     "mode": ["BUFFER_UNION"] * 3,
     "nodes": [int((reach <= m * 60).sum()) for m in (5, 10, 15)]},
    geometry=[polygons[("BUFFER_UNION", m)] for m in (5, 10, 15)],
    crs="EPSG:4326").to_crs(METRIC_CRS)

reach_gdf = ox.graph_to_gdfs(G, edges=False)[["geometry"]].copy()
reach_gdf["travel_time_s"] = [seconds.get(n, np.nan) for n in reach_gdf.index]
reach_gdf["detour"] = [float(detour[position[n]]) for n in reach_gdf.index]
reach_gdf["cluster_eps80"] = [int(cluster_of[n]) for n in reach_gdf.index]

snap_out = pois[["amenity", "geometry"]].copy()
snap_out["nearest_node"] = nearest_id
snap_out["snap_m"] = nearest_m
snap_out["recorded_by_operator"] = written

sg.graphs.save_gdf(isochrone_gdf, out_dir / "isochrones.gpkg")
sg.graphs.save_gdf(reach_gdf, out_dir / "nodes_reachability.gpkg")
sg.graphs.save_gdf(snap_out, out_dir / "amenities_snapped.gpkg")

print("written to", nb.rel(out_dir))
for f in sorted(out_dir.iterdir()):
    if f.is_file():
        print("  ", f.name, f"{f.stat().st_size / 1e3:.0f} kB")
written to notebooks/out/09_osmnx_accessibility
   amenities_snapped.gpkg 152 kB
   isochrones.gpkg 430 kB
   nodes_reachability.gpkg 270 kB

Rendering

Six figures, all EEVEE through sg.render, all straight down through an orthographic camera, all look='ink' and its turbo ramp on a near-black backdrop, so a plate here can be laid beside one from notebook 17 or 11 and only the data will have changed. Figures 2, 3 and 4 are the notebook 04 overlays of section 2, with tubes 4.0x the context’s.

Two adjustments for an OSMnx mesh. node_fraction=0.16 rather than the 0.35 default, which sizes a node sphere at 35 % of the median distance between neighbors and is calibrated for an abstract graph on an empty field: on a street network the context’s own spheres swallow any highlight thinner than they are, and they still have to read as a network, which rules out zero. And the highlight’s spheres go to 2 % of the context’s, because both objects hold real intersections at the same coordinates and the two would z-fight. Only the tubes carry the highlight.

The *_context.png files in out/renders/12_osmnx_accessibility/ are the second render of each overlay pair, measurement instruments rather than figures.

One trap, invisible to any check. A graph object keeps its coloring, for the reason the helpers cell gives, and this notebook draws the same network object under four attributes (travel time, the flat context neutral, detour, cluster). plain() clears the color layers, the two scene properties and the material slot before every render that changes what the object is showing; without it figures 5 and 6 would both come out as figure 1. nb.ink() cannot detect that failure, because the same graph under two colormaps has the same coverage, so the check is that the figures differ, and looking at them.

The Vulkan warning still applies to anything drawn through the SciGraphs GPU engine rather than EEVEE: on Blender’s Vulkan backend, the default on Linux, gpu.state.point_size_set does nothing for the add-on’s shaders and POINT and DISK nodes come out 1 pixel wide. Start Blender with --gpu-backend opengl for any render you intend to look at. This notebook uses EEVEE throughout and is not affected, but verify_notebooks.py passes the flag anyway.

Summary

Operator What it really does Where it bites
osmnx_isochrones Dijkstra on travel_time, or on length / speed: a real cost, unlike the city2graph operator’s hop count the speed is imputed once and never updated; the polygon keeps the exterior ring only; buffer_m / 111000 is degrees
osmnx_ego_subgraph nx.ego_graph with distance="length", always meters, always outbound roots happily on a sink; replaces the cached graph without rebuilding the mesh, and moves the origin everything else is drawn from
osmnx_features_* six ways to name an area, one Overpass query routes to Overture unless feat_source says otherwise; CUSTOM cannot express a multi-value tag
osmnx_snap_pois nearest node, not nearest street picks whichever network sorts first in the scene; 43 % of the ids are lost to a 32-bit attribute
osmnx_network_dbscan DBSCAN on an all-pairs network distance matrix O(n²); street shape points default into cluster 0

Next: 14 · Centrality, and getting it back out.

Back to top