aperta.network_snap

Snap-target resolution on transport networks.

This module separates two questions that the previous tiered design conflated:

  • Where should new graph nodes be introduced so that snap targets are available where points need them? Answered by insert_projected_nodes (mutates the graph by inserting virtual nodes onto eligible edges).

  • Given a graph (enriched with new nodes or not), which node does each point match to? Answered by snap_to_network_nodes (read-only; optionally two-tier with a priority node set for “prefer main-road nodes” semantics).

The typical workflow on a study region is one or more insert_projected_nodes calls (e.g., one for cells with a wide edge filter, one for zone centroids restricted to main-road tags) followed by snap_to_network_nodes per point layer that needs a (point → node_id) mapping. The enriched graph is reusable across many layers; the snap function never mutates it.

Other primitives in this module:

  • split_edge_at_point and split_two_way_edge_at_point — the edge-split primitives used by insert_projected_nodes. Exposed for advanced callers that want to drive insertions directly.

  • transport_centroid — polygon-aware helper that produces a representative point per polygon (the median of contained eligible nodes) before snapping.

  • nodes_incident_to_edges — derive a node set (typically the “priority” node set for snap_to_network_nodes) from an edge tag predicate.

Eligibility / priority node sets that this module’s functions consume are typically produced by aperta.routing_prepcompute_snap_eligibility (prep-stage, returns just the eligible frozenset + cost-mask flag name) or prepare_network (consumer-stage, returns a full PreparedGraph with the same eligible set plus per-node is_snap_eligible_<mode> decorations). Priority sets are typically derived from edge tags via the local nodes_incident_to_edges helper. This module does not depend on routing_prep — its functions accept node sets (or per-node flag-attribute names) as plain arguments.

class aperta.network_snap.SplitEdgeResult(new_node_id, child1, child2)

Bases: tuple

child1

Alias for field number 1

child2

Alias for field number 2

new_node_id

Alias for field number 0

aperta.network_snap.split_edge_at_point(graph, u, v, point, *, k=None, new_node_id=None, extra_node_attrs=None)[source]

Insert a synthetic node on edge (u, v[, k]) at the projection of point onto the edge geometry; split the parent edge into two children.

Single-direction split. For two-way OSM roads (both (u, v, k_fwd) and (v, u, k_rev) exist), use split_two_way_edge_at_point instead — it handles symmetric routing through one shared virtual node. Use this function directly only for genuinely one-way roads or when asymmetric routing is intended.

Load-bearing primitive of insert_projected_nodes: a point with no eligible node within node_spacing of its projection on the nearest candidate edge gets a virtual node here, on that edge, so the next snap_to_network_nodes call can match it.

Per-edge attribute split semantics:

  • geometry: sliced into two LineStrings via shapely substring at the projected fractional position. Both children inherit valid LineStrings.

  • length: recomputed as float(child_geom.length) on each child (matches the consolidate_intersections convention).

  • All other per-edge attributes: copied verbatim to both children. Correct for OSM-direct properties (highway, lanes, oneway, junction, cycleway*, maxspeed) and for the per-edge cost-mask flag (cost_excluded_<mode>), which depend only on the road class (uniform along the parent). Attributes that DO require recomputation after split (elev_gain / elev_loss, endpoint-mean-propagated intersection flags, density_norm on edges) are expected to be (re)derived AFTER insertion by the post-insertion enrichment passes (cf. snap-across-barrier 7-step workflow).

Per-node attributes on the new synthetic node:

  • x, y: from the projected point on the parent geometry (so the new node sits exactly on the road).

  • Any caller-supplied extra_node_attrs. Topology / classification flags (n_streets, is_t_junction, is_4way, the _major / _anchor variants, max/min_highway_rank) are NOT computed here; they belong to flag_node_intersection_topology / flag_node_osm_classification, run on the augmented graph by the post-insertion prepare_network pass.

Parameters:
  • graph (Graph) – networkx Graph / MultiGraph / DiGraph / MultiDiGraph. For multigraphs, k is required; for simple graphs k must be None. Directional edges (DiGraph / MultiDiGraph) are split preserving direction: child 1 is (u, new), child 2 is (new, v). A reverse edge (v, u) is NOT touched; the caller must split it separately if a two-way road is intended.

  • u – endpoints of the parent edge.

  • v – endpoints of the parent edge.

  • point (tuple[float, float] | Point) – the snap-target point. Projected onto the parent geometry to find the split fraction; the projection (not the input point) becomes the new node’s (x, y). Accepts (x, y) tuple or shapely Point.

  • k – edge key for multigraphs.

  • new_node_id – optional explicit id for the synthetic node. If None, auto-derived as max(int_node_ids) + 1 (raises if the graph has no int-typed node ids — pass new_node_id explicitly).

  • extra_node_attrs (dict | None) – optional dict of additional attributes to set on the new node beyond x / y.

Returns:

SplitEdgeResult(new_node_id, child1_edge_key, child2_edge_key).

Raises:

ValueError – if the edge doesn’t exist, k is mis-supplied for the graph type, the parent edge has no geometry, the projection falls at or beyond an endpoint (caller should snap to that endpoint instead), or new_node_id collides with an existing node.

Return type:

SplitEdgeResult

class aperta.network_snap.SplitTwoWayEdgeResult(new_node_id, forward, reverse)

Bases: tuple

forward

Alias for field number 1

new_node_id

Alias for field number 0

reverse

Alias for field number 2

aperta.network_snap.split_two_way_edge_at_point(graph, u, v, point, *, k_forward, k_reverse, new_node_id=None, extra_node_attrs=None)[source]

Symmetrically split BOTH directional edges (u, v, k_forward) and (v, u, k_reverse) through a single shared synthetic node. Use this for two-way roads (typical OSMnx case) where the snap-target should be reachable from both directions.

After the split: four new edges exist — (u, new), (new, v), (v, new), (new, u) — and the original two are gone. The shared synthetic node implicitly allows a U-turn through it (A → new → A), which is a property of any node in the graph; shortest-path routing won’t generate U-turns at a virtual node unless it shortens the route (it doesn’t), so the practical impact is negligible. Use split_edge_at_point directly if you want an asymmetric split (e.g., for a genuinely one-way road with no reverse edge to also split).

The two directional edges are typically near-mirror geometries of one another, but not required to be exact mirrors — each direction’s projection is computed independently against its own geometry. If they disagree noticeably on where the projection lands, the new node sits at the FORWARD direction’s projection (the reverse direction’s split still uses its own projection along its own geometry, so the two children summing to the parent’s length is preserved on each direction separately).

Parameters:
  • graph (MultiDiGraph) – nx.MultiDiGraph (the typical OSMnx graph type).

  • u – endpoints of the forward edge. The reverse edge is queried as (v, u, k_reverse).

  • v – endpoints of the forward edge. The reverse edge is queried as (v, u, k_reverse).

  • point (tuple[float, float] | Point) – snap-target point, used as the projection target on both geometries. The forward direction’s projection determines the new node’s (x, y).

  • k_forward – edge key of the forward edge (u, v).

  • k_reverse – edge key of the reverse edge (v, u).

  • new_node_id – optional explicit id for the synthetic node (else auto-derived as max(int_node_ids) + 1).

  • extra_node_attrs (dict | None) – optional additional attributes for the new node.

Returns:

SplitTwoWayEdgeResult(new_node_id, forward, reverse) where forward and reverse are each a SplitEdgeResult describing the two children of that direction.

Raises:

ValueError – if graph isn’t a directed multigraph, either edge is missing or lacks geometry, projection on either direction falls at/beyond an endpoint, or new_node_id collides with an existing node.

Return type:

SplitTwoWayEdgeResult

aperta.network_snap.snap_to_network_nodes(points, graph, *, max_distance=None, eligible_node_ids=None, eligible_node_flag=None, priority_node_ids=None, priority_node_flag=None, priority_node_max_distance=None)[source]

Snap each row in points to its nearest node in graph.

Two-tier when a priority node set is given:

  1. Tier 1 — priority node snap: if a priority set is provided (via priority_node_ids or priority_node_flag), snap to the nearest priority node within priority_node_max_distance. The priority set is typically the nodes incident to one or more high-class edges (e.g., main roads); the helper nodes_incident_to_edges derives such a set from an edge predicate.

  2. Tier 2 — eligible node snap (fallback): for unmatched points, snap to the nearest eligible node within max_distance. Points beyond max_distance from every eligible node return NaN.

When no priority set is given (default), the function collapses to a single-pass eligible-node snap within max_distance — bit-identical to the historical single-tier behavior.

This function does NOT mutate graph and does NOT insert nodes. For node introduction (virtual-node insertion onto edges), call insert_projected_nodes first to enrich the graph; then call this function for the actual snap. The two-step pattern lets the enriched graph be reused for many subsequent point-set snaps cheaply.

Network nodes must carry x and y attributes (aperta convention). Point CRS and the graph coordinates must already agree — no reprojection.

Parameters:
  • points (GeoDataFrame | GeoSeries) – GeoDataFrame or GeoSeries of points to snap. Output is indexed by points.index.

  • graph (Graph) – NetworkX (or compatible) graph with x / y node attributes.

  • max_distance (float | None) – optional cap on tier-2 (fallback) snap distance. None means no cap. Points farther than this from every eligible node return NaN.

  • eligible_node_ids (set | list | Index | None) – optional restriction on the tier-2 (fallback) candidate set. None considers all graph nodes. Mutually exclusive with eligible_node_flag.

  • eligible_node_flag (str | None) – per-node bool attribute name as an alternative to eligible_node_ids (e.g., snap_eligible_<mode> from prepare_network).

  • priority_node_ids (set | list | Index | None) – optional priority set for tier-1 snap. If given, points within priority_node_max_distance of a priority node snap to that node; unmatched points fall through to tier 2. Mutually exclusive with priority_node_flag.

  • priority_node_flag (str | None) – per-node bool attribute name as an alternative to priority_node_ids.

  • priority_node_max_distance (float | None) – tier-1 search radius. Required when a priority set is given; ignored otherwise. Typically equal to or larger than the spacing used by insert_projected_nodes so that virtual nodes inserted on priority edges are found.

Returns:

  • node_ids: pd.Series of nearest-node IDs, indexed by points.index.

  • distances: pd.Series of distances (in CRS units), indexed by points.index.

Return type:

Tuple (node_ids, distances)

aperta.network_snap.nodes_incident_to_edges(graph, edge_tags)[source]

Return the set of node IDs incident to at least one edge matching edge_tags.

Common use: derive the priority-node set for the two-tier snap_to_network_nodes. Pass the same tag set used to define “priority edges” upstream (e.g., {‘primary’, ‘secondary’, ‘tertiary’} for car main-road analyses); the returned set is the union of endpoints of all such edges in the graph.

Parameters:
  • graph (Graph) – NetworkX graph. Multigraphs are walked with keys=True.

  • edge_tags (Iterable[str] | Callable[[dict], bool] | None) – either an iterable of OSM highway tag strings (matched against the edge’s highway attribute, tolerating list-valued tags) OR a callable (edge_data) -> bool. None returns the empty set.

Returns:

Set of node IDs.

Return type:

set

aperta.network_snap.insert_projected_nodes(points, graph, *, max_distance, node_spacing=50.0, eligible_node_ids=None, eligible_node_flag=None, cost_excluded_flag=None, edge_filter=None, verbose=False)[source]

Enrich graph with virtual nodes so every point in points has a graph node within snap distance, for use by a subsequent snap_to_network_nodes call.

For each point in points, finds the nearest filter-eligible edge within max_distance. Project the point onto that edge:

  • If the projection lands within node_spacing of either endpoint of the edge, do NOTHING — the endpoint will serve as the snap target when snap_to_network_nodes runs on the enriched graph.

  • Otherwise, insert a virtual node at the projection and split the edge. The new node carries is_virtual=1. Child edges inherit the parent’s attributes (including highway, so a virtual on a primary road shows up as priority for downstream tools that derive priority-node sets from edge tags).

Points beyond max_distance from any filter-eligible edge contribute no insertion.

This function MUTATES graph in place and returns nothing. The (point → node) mapping comes from the subsequent snap_to_network_nodes call; persisting the snap result is the caller’s responsibility.

Composability. The function can be called repeatedly on the same graph with different edge_filter values, accumulating virtuals suited to different needs:

# Cells: densify any non-highway edge near each cell.
insert_projected_nodes(
    cells_gdf, graph,
    max_distance=200, node_spacing=75,
)
# Zones on main roads only.
insert_projected_nodes(
    zones_gdf, graph,
    max_distance=1000, node_spacing=250,
    edge_filter={'primary', 'secondary', 'tertiary'},
)

Existing virtual nodes from earlier calls are respected: the per-edge endpoint check on node_spacing means a new projection that falls near an already-inserted virtual yields no further insertion (the existing virtual becomes the snap target).

Single-pass via in-memory lineage walk. Processing is a single straight pass through points. The STRtree is built once at the start and never rebuilt. When a point’s nearest-edge query returns a parent that’s already been split earlier in this call, an in-memory child-lineage walk re-projects onto the appropriate descendant child — no STRtree rebuild required. The walk asserts an invariant (every freshly-split parent has a paired child_lineage entry from the same insertion block) and raises if violated.

Edge eligibility: an edge is a candidate for insertion if both endpoints are in the eligible node set, it isn’t cost-excluded (per cost_excluded_flag, if supplied), and it passes edge_filter.

Parameters:
  • points (GeoDataFrame) – GeoDataFrame of points whose projections may trigger insertions.

  • graph (Graph) – NetworkX graph with x/y node attributes; edges must carry geometry LineStrings (consolidated OSMnx graphs satisfy this).

  • max_distance (float | None) – max distance from a point to its nearest candidate edge. Points farther than this contribute no insertion. None = unbounded.

  • node_spacing (float) – minimum spacing between an insertion and an existing endpoint of the chosen edge. If the projection lands within this radius of either endpoint, no insertion happens (the endpoint will serve). Default 50.0.

  • eligible_node_ids (set | list | Index | None) – restrict candidate edges to those with both endpoints in this set. None (default) allows all nodes. Mutually exclusive with eligible_node_flag.

  • eligible_node_flag (str | None) – alternative to eligible_node_ids — name of a per-node bool attribute marking eligible endpoints (e.g., snap_eligible_<mode> from prepare_network).

  • cost_excluded_flag (str | None) – name of a per-edge bool attribute marking cost-excluded edges; if set, those edges are skipped.

  • edge_filter (Iterable[str] | Callable[[dict], bool] | None) – which edges may receive a virtual node. None (default) excludes motorway / trunk classes (see DEFAULT_EXCLUDED_HIGHWAY_TAGS); an Iterable[str] is an allowlist of OSM highway tag strings (e.g. {‘primary’, ‘secondary’, ‘tertiary’} to only densify main roads); a Callable[[dict], bool] is an arbitrary predicate on edge data.

  • verbose (bool) – if True, print one summary line with insertion / skip / no-match counts. Useful on large networks for sanity-checking that the filter and radius parameters are tuned reasonably.

Returns:

None. Mutates graph in place.

Return type:

None

aperta.network_snap.transport_centroid(polygons, graph, eligible_node_ids, *, centroid_method='median', fallback_to_geometric_centroid=True)[source]

Per-polygon centroid of the eligible network nodes that fall inside it.

For each polygon, find the eligible nodes whose location is within the polygon and return the median (or mean) of their (x, y) coordinates as a single point. Output is a GeoDataFrame of points indexed by polygon ID — ready to feed into insert_projected_nodes (to enrich the network if needed) followed by snap_to_network_nodes (to get the polygon-to- node mapping).

Why centroid-then-snap instead of centroid-only or sjoin-only: zones (especially uniformly-tiled units like H3 hexes) often have geometric centroids that land on an arbitrary minor node — a service road, a dead-end, or worse. The median / mean of the eligible nodes within the polygon produces a “gravitational centre” of the polygon’s transportation grid, biased toward where the routable network actually is. Snapping from there (separately, via snap_to_network_nodes) reliably lands on a more representative node.

Polygons with no eligible node inside fall back to their geometric centroid if fallback_to_geometric_centroid=True (default). Otherwise they’re dropped from the output.

Parameters:
  • polygons (GeoDataFrame) – GeoDataFrame of polygons. Output preserves polygons.index and polygons.crs.

  • graph (Graph) – NetworkX graph with x / y node attributes.

  • eligible_node_ids (set | list | Index) – set of nodes considered when computing the centroid. Typically the snap-eligible set from prepare_network (prepared.snap_eligible_nodes).

  • centroid_method (Literal['median', 'mean']) – ‘median’ (default) or ‘mean’. Median is more robust against outlier nodes (e.g., a single highway-on-ramp node included by accident).

  • fallback_to_geometric_centroid (bool) – when True, polygons with no eligible node inside fall back to their geometric centroid. When False, such polygons are dropped from the output (the returned GeoDataFrame’s index is a subset of polygons.index).

Returns:

gpd.GeoDataFrame of Point geometries, indexed by polygons.index (or a subset when fallback_to_geometric_centroid=False).

Return type:

GeoDataFrame