Skip to content

Input data

Edge table contract

SpatialDeformer.fit(X, y=None) expects X to be a non-empty geopandas.GeoDataFrame with LineString or MultiLineString geometry.

Required information:

  1. a geometry for every edge;
  2. a positive travel cost for every edge, or an explicit missing-cost policy;
  3. a way to identify the source and target node.

Explicit node identifiers

The recommended form contains source and target columns:

source target travel_time_s geometry
A B 74.2 LINESTRING (…)
B C 105.0 LINESTRING (…)
model = SpatialDeformer(
    source="source",
    target="target",
    cost="travel_time_s",
)

Identifiers may be strings, integers, or any hashable scalar. Geometry order is assumed to run from source to target.

Geometry endpoint inference

If endpoint columns are unavailable, use geometry coordinates as node IDs:

model = SpatialDeformer(
    source=None,
    target=None,
    cost="travel_time_s",
    node_precision=9,
)

Coordinates are rounded to node_precision decimal places before matching. Lines that should connect must therefore share endpoints after rounding.

Snapping is not topology repair

node_precision handles small floating-point differences. It does not snap visibly separated lines, split crossings, or create missing junctions. Clean the network with an appropriate GIS topology workflow first.

Travel costs

Costs can represent time, generalized cost, distance, energy, or another positive additive quantity. Shortest paths sum edge costs.

Zero and negative costs always raise a ValueError. Missing values (NaN or infinity) raise by default, but can be handled explicitly with missing_cost:

Policy Behavior Best used when
"error" list the affected edge indices and stop completeness is required
"drop" omit those edges from graph fitting the remaining network stays connected
"length" use working-CRS length / fallback_speed a defensible fallback speed is known
"median" use the global median, or a group median with impute_by peer edges are comparable
callable run a project-specific resolver domain rules or an external model are available

For example, use road-class medians and inspect exactly what changed:

model = SpatialDeformer(
    cost="travel_time_s",
    missing_cost="median",
    impute_by="road_class",
)
warped = model.fit_transform(edges)

print(model.imputed_edges_)  # input index labels
print(model.dropped_edges_)
print(model.costs_)          # resolved values, aligned with edges
print(model.edge_mask_)      # rows used to fit the graph

Groups with no observed value fall back to the global median. Length-based imputation uses the projected working CRS, so with metre coordinates and costs in seconds, fallback_speed must be metres per second:

model = SpatialDeformer(
    missing_cost="length",
    fallback_speed=8.33,  # about 30 km/h
)

A callable receives copies of the input GeoDataFrame and cost array and must return one positive finite value per row:

import numpy as np


def resolve_costs(frame, costs):
    missing = ~np.isfinite(costs)
    costs[missing] = frame.loc[missing, "modelled_time_s"]
    return costs

model = SpatialDeformer(missing_cost=resolve_costs)

Dropping can break connectivity

missing_cost="drop" excludes edges only from graph fitting; transformed output still contains every input geometry. If excluded edges disconnect the fitted graph, fitting raises the normal disconnected-components error.

Other invalid inputs raise a ValueError:

  • zero or negative costs;
  • an array whose length differs from len(X).

For asymmetric networks, set directed=True. A Euclidean embedding is necessarily symmetric, so reciprocal path costs are combined using directed_policy="mean", "min", or "max".

Connected components

One estimator currently accepts one connected component. Disconnected input raises an actionable error rather than silently inventing distances.

component_models = {}
outputs = []

for component_id, component_edges in split_connected_components(edges):
    model = SpatialDeformer(cost="travel_time_s")
    outputs.append(model.fit_transform(component_edges))
    component_models[component_id] = model

This keeps component placement an explicit cartographic decision.

CRS rules

Input CRS Behavior
projected optimized directly in that CRS
geographic and auto_project=True estimated UTM working CRS, then restored
geographic and auto_project=False rejected
absent coordinates treated as planar unitless values

Use a local projected CRS when a network crosses UTM zones, spans a continent, or requires a specific datum.

Geometry deformation

Graph nodes move exactly to their fitted embedding coordinates. Interior line vertices use inverse-distance weighted node displacement:

\[ \Delta(p)=\frac{\sum_{i\in N_k(p)} d(p,i)^{-q}\Delta_i} {\sum_{i\in N_k(p)} d(p,i)^{-q}} \]

where n_neighbors is \(k\) and displacement_power is \(q\). Larger powers make nearby nodes dominate more strongly.

Z coordinates are retained while X/Y are deformed.