Package: osmh3nx
Version: v0.1.0b1
Module: osmh3nx/driveshed.py
Function: _build_reachable_edges_gdf()
Summary
When build_h3_driveshed_from_point() is called for an origin point that is
isolated in the H3 graph — meaning Dijkstra finds exactly 1 reachable node
(the origin cell itself, at distance 0) — the function raises a
ValueError: Unknown column geometry inside _build_reachable_edges_gdf().
This is a reproducible edge case, not a corrupted graph. The H3 graph itself
is valid. The origin snapped successfully. Dijkstra ran successfully. The error
occurs in the output layer construction phase, specifically when the
reachable-edges list turns out to be empty.
Root Cause
_build_reachable_edges_gdf() (driveshed.py) builds a list of row dicts and
then constructs a GeoDataFrame from it:
def _build_reachable_edges_gdf(
*,
h3_graph: nx.Graph,
distances: Dict[str, float],
cutoff_seconds: float,
weight_attr: str,
) -> gpd.GeoDataFrame:
rows: List[Dict[str, Any]] = []
for a, b, data in h3_graph.edges(data=True):
if not _edge_is_within_cutoff(...):
continue
...
rows.append({..., "geometry": LineString(...)})
return gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326") # <-- bug here
When only the origin cell is reachable, _edge_is_within_cutoff() returns
False for every edge in the graph because all neighbor nodes have
dist_b = None (not in the distances dict). rows remains an empty list.
Passing an empty list to the gpd.GeoDataFrame() constructor produces a
DataFrame with no columns, so the subsequent geometry="geometry" kwarg
cannot resolve the geometry column and geopandas raises:
ValueError: Unknown column geometry
Why only 1 reachable node occurs in practice
This is observed for installations situated on isolated road segments —
specifically cases where:
- The origin snaps to a valid H3 cell that is present in the H3 graph.
- That cell's only H3-graph neighbors require more than
max_travel_minutes
to reach (e.g., a very short dead-end road, or a road segment too short
to produce any H3 transition at resolution 10 within the time budget).
- Dijkstra therefore returns
distances = {origin_cell: 0.0} and
paths = {origin_cell: [origin_cell]} — one entry, zero edges in the
reachable subgraph.
How to Reproduce
import networkx as nx
from shapely.geometry import Point
from osmh3nx import driveshed as dshed
import h3
# Build a minimal H3 graph: one node, no outbound edges.
# This simulates an origin that is topologically isolated.
origin = Point(-77.4533, 38.9282) # any coordinate
origin_cell = h3.latlng_to_cell(origin.y, origin.x, 10)
h3_graph = nx.Graph()
h3_graph.add_node(origin_cell, travel_time_route=0.0, travel_time_postcalibrated=0.0)
h3_graph.graph["h3_res"] = 10
h3_graph.graph["calibration_profile_name"] = "default"
h3_graph.graph["default_query_weight_attr"] = "travel_time_route"
h3_graph.graph["report_weight_attr"] = "travel_time_postcalibrated"
# This raises ValueError: Unknown column geometry
result = dshed.build_h3_driveshed_from_point(
origin,
max_travel_minutes=27.5,
h3_res=10,
h3_graph=h3_graph,
)
The traceback ends at:
File "osmh3nx/driveshed.py", line <N>, in build_h3_driveshed_from_point
reachable_edges_gdf = _build_reachable_edges_gdf(...)
File "osmh3nx/driveshed.py", line <N>, in _build_reachable_edges_gdf
return gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326")
...
ValueError: Unknown column geometry
Contrast with Existing Handling Elsewhere
_rows_to_gdf() in batch.py already handles the empty-list case correctly:
def _rows_to_gdf(rows, *, geometry_col):
if not rows:
return gpd.GeoDataFrame(
columns=[geometry_col], geometry=geometry_col, crs=DEFAULT_POINT_CRS
)
return gpd.GeoDataFrame(list(rows), geometry=geometry_col, crs=DEFAULT_POINT_CRS)
_build_reachable_edges_gdf() needs the same guard.
Proposed Fix
In driveshed.py, add an empty-list guard at the end of
_build_reachable_edges_gdf(), defining the full column schema explicitly so
downstream code that inspects column names gets a consistent result:
_REACHABLE_EDGES_COLUMNS: Tuple[str, ...] = (
"from_h3_cell",
"to_h3_cell",
"is_directed_graph",
"travel_time_sec",
"travel_time_minutes",
"observed_step_time_raw_sec",
"step_time_floored_sec",
"step_time_route_sec",
"step_time_postcalibrated_sec",
"centroid_dist_miles",
"floor_applied",
"geometry",
)
def _build_reachable_edges_gdf(
*,
h3_graph: nx.Graph,
distances: Dict[str, float],
cutoff_seconds: float,
weight_attr: str,
) -> gpd.GeoDataFrame:
rows: List[Dict[str, Any]] = []
for a, b, data in h3_graph.edges(data=True):
if not _edge_is_within_cutoff(
h3_graph=h3_graph,
edge=(a, b),
distances=distances,
cutoff_seconds=cutoff_seconds,
weight_attr=weight_attr,
):
continue
lat_a, lng_a = h3.cell_to_latlng(a)
lat_b, lng_b = h3.cell_to_latlng(b)
rows.append(
{
"from_h3_cell": a,
"to_h3_cell": b,
"is_directed_graph": bool(h3_graph.is_directed()),
"travel_time_sec": float(data.get(weight_attr, 0.0)),
"travel_time_minutes": float(data.get(weight_attr, 0.0))
/ SECONDS_PER_MINUTE,
"observed_step_time_raw_sec": data.get("observed_step_time_raw_sec"),
"step_time_floored_sec": data.get("step_time_floored_sec"),
"step_time_route_sec": data.get("step_time_route_sec"),
"step_time_postcalibrated_sec": data.get("step_time_postcalibrated_sec"),
"centroid_dist_miles": data.get("centroid_dist_miles"),
"floor_applied": data.get("floor_applied"),
"geometry": LineString([(lng_a, lat_a), (lng_b, lat_b)]),
}
)
# Guard against empty rows: GeoDataFrame([], geometry="geometry") raises
# ValueError: Unknown column geometry because there are no columns to resolve.
if not rows:
return gpd.GeoDataFrame(
columns=list(_REACHABLE_EDGES_COLUMNS),
geometry="geometry",
crs="EPSG:4326",
)
return gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326")
Observed Behavior vs. Expected Behavior
|
Current |
Expected |
| Dijkstra finds 0 reachable edges |
ValueError: Unknown column geometry |
Returns DriveshedResult with reachable_edges_gdf having 0 rows and correct column schema |
reachable_cells_gdf |
1 row (the origin cell at time 0.0) |
unchanged |
driveshed_gdf |
never reached |
1-polygon result (the origin cell dissolved) |
The 1-node-reachable case is a valid, degenerate driveshed — not an error
state. The function should return it with reachable_edges_gdf as an empty
GeoDataFrame rather than raising. Callers can decide whether a 1-cell
driveshed is meaningful for their use case.
Additional Notes
- The same guard may be worth verifying for
_build_reachable_cells_gdf(),
though in practice distances always contains at least the origin cell at
distance 0, so rows there is never empty as long as snapping succeeded.
- A corresponding unit test covering the 1-reachable-node case would prevent
regression, particularly since continue_on_error=True in batch workflows
will silently swallow this exception and mark the origin as an error row
instead of returning a valid (if minimal) result.
- Claude Code Sonnet-4.6
Package:
osmh3nxVersion: v0.1.0b1
Module:
osmh3nx/driveshed.pyFunction:
_build_reachable_edges_gdf()Summary
When
build_h3_driveshed_from_point()is called for an origin point that isisolated in the H3 graph — meaning Dijkstra finds exactly 1 reachable node
(the origin cell itself, at distance 0) — the function raises a
ValueError: Unknown column geometryinside_build_reachable_edges_gdf().This is a reproducible edge case, not a corrupted graph. The H3 graph itself
is valid. The origin snapped successfully. Dijkstra ran successfully. The error
occurs in the output layer construction phase, specifically when the
reachable-edges list turns out to be empty.
Root Cause
_build_reachable_edges_gdf()(driveshed.py) builds a list of row dicts andthen constructs a GeoDataFrame from it:
When only the origin cell is reachable,
_edge_is_within_cutoff()returnsFalsefor every edge in the graph because all neighbor nodes havedist_b = None(not in thedistancesdict).rowsremains an empty list.Passing an empty list to the
gpd.GeoDataFrame()constructor produces aDataFrame with no columns, so the subsequent
geometry="geometry"kwargcannot resolve the geometry column and geopandas raises:
Why only 1 reachable node occurs in practice
This is observed for installations situated on isolated road segments —
specifically cases where:
max_travel_minutesto reach (e.g., a very short dead-end road, or a road segment too short
to produce any H3 transition at resolution 10 within the time budget).
distances = {origin_cell: 0.0}andpaths = {origin_cell: [origin_cell]}— one entry, zero edges in thereachable subgraph.
How to Reproduce
The traceback ends at:
Contrast with Existing Handling Elsewhere
_rows_to_gdf()inbatch.pyalready handles the empty-list case correctly:_build_reachable_edges_gdf()needs the same guard.Proposed Fix
In
driveshed.py, add an empty-list guard at the end of_build_reachable_edges_gdf(), defining the full column schema explicitly sodownstream code that inspects column names gets a consistent result:
Observed Behavior vs. Expected Behavior
ValueError: Unknown column geometryDriveshedResultwithreachable_edges_gdfhaving 0 rows and correct column schemareachable_cells_gdfdriveshed_gdfThe 1-node-reachable case is a valid, degenerate driveshed — not an error
state. The function should return it with
reachable_edges_gdfas an emptyGeoDataFrame rather than raising. Callers can decide whether a 1-cell
driveshed is meaningful for their use case.
Additional Notes
_build_reachable_cells_gdf(),though in practice
distancesalways contains at least the origin cell atdistance 0, so
rowsthere is never empty as long as snapping succeeded.regression, particularly since
continue_on_error=Truein batch workflowswill silently swallow this exception and mark the origin as an error row
instead of returning a valid (if minimal) result.
- Claude Code Sonnet-4.6