Skip to content

Bug: ValueError: Unknown column geometry when Dijkstra finds only the origin node #7

Description

@cooperjaXC

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:

  1. The origin snaps to a valid H3 cell that is present in the H3 graph.
  2. 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).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions