Skip to content

Commit e4631d4

Browse files
committed
Fix tessellation fallback recovery
1 parent d259788 commit e4631d4

5 files changed

Lines changed: 147 additions & 9 deletions

File tree

city2graph/morphology.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2338,9 +2338,17 @@ def _add_building_info(
23382338
joined["index_right"] = None
23392339

23402340
if _SOURCE_BUILDING_INDEX_COL in joined.columns:
2341+
# Fallback cells are matched exactly to their source building, so extra
2342+
# sjoin matches only duplicate the cell's row and must be dropped. All
2343+
# operations are positional: the sjoin can duplicate index labels when a
2344+
# cell contains several building points, which breaks label alignment.
2345+
source_mask = joined[_SOURCE_BUILDING_INDEX_COL].notna().to_numpy()
2346+
redundant = source_mask & joined.index.duplicated(keep="first")
2347+
if redundant.any():
2348+
joined = joined.loc[~redundant]
2349+
source_mask = joined[_SOURCE_BUILDING_INDEX_COL].notna().to_numpy()
23412350
source_index = joined[_SOURCE_BUILDING_INDEX_COL]
2342-
joined["index_right"] = source_index.combine_first(joined["index_right"])
2343-
source_mask = source_index.notna()
2351+
joined["index_right"] = np.where(source_mask, source_index, joined["index_right"])
23442352
for column in building_columns:
23452353
joined.loc[source_mask, column] = (
23462354
source_index.loc[source_mask]

city2graph/utils.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4496,7 +4496,18 @@ def _repair_or_drop_degenerate_enclosures(
44964496
"grid_size=1e-3.",
44974497
len(broken),
44984498
)
4499-
tessellation = run_tessellation(**{**overrides, "grid_size": 1e-3})
4499+
retry_overrides = {**overrides, "grid_size": 1e-3}
4500+
try:
4501+
tessellation = run_tessellation(**retry_overrides)
4502+
except TypeError as e:
4503+
if "incorrect geometry type" not in str(e) or "simplify" in kwargs:
4504+
raise
4505+
logger.warning(
4506+
"Tessellation retry failed boundary simplification (%s); retrying with "
4507+
"simplify=False.",
4508+
e,
4509+
)
4510+
tessellation = run_tessellation(**{**retry_overrides, "simplify": False})
45004511
broken = _overfilled_enclosures(tessellation, enclosures)
45014512
if broken:
45024513
logger.warning(

tests/test_graph.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,12 +2315,38 @@ def test_osmnx_shaped_multigraph_gdfs_round_trip_deduplicates_symmetrized_edges(
23152315
)
23162316

23172317
def test_osmnx_downloaded_multidigraph_smoke(self) -> None:
2318-
"""Downloaded OSMnx MultiDiGraph round-trips through PyG with keyed edges."""
2319-
graph = ox.graph_from_bbox(
2320-
(-0.128, 51.501, -0.124, 51.503),
2321-
network_type="drive",
2322-
simplify=True,
2323-
retain_all=False,
2318+
"""OSMnx MultiDiGraph round-trips through PyG with keyed edges."""
2319+
graph = nx.MultiDiGraph()
2320+
graph.graph["crs"] = "EPSG:4326"
2321+
graph.add_node(101, x=-0.128, y=51.501)
2322+
graph.add_node(202, x=-0.126, y=51.502)
2323+
graph.add_node(303, x=-0.124, y=51.503)
2324+
graph.add_edge(
2325+
101,
2326+
202,
2327+
key=0,
2328+
osmid=10,
2329+
length=100.0,
2330+
geometry=LineString([(-0.128, 51.501), (-0.126, 51.502)]),
2331+
oneway=True,
2332+
)
2333+
graph.add_edge(
2334+
202,
2335+
303,
2336+
key=0,
2337+
osmid=11,
2338+
length=110.0,
2339+
geometry=LineString([(-0.126, 51.502), (-0.124, 51.503)]),
2340+
oneway=True,
2341+
)
2342+
graph.add_edge(
2343+
202,
2344+
101,
2345+
key=0,
2346+
osmid=12,
2347+
length=100.0,
2348+
geometry=LineString([(-0.126, 51.502), (-0.128, 51.501)]),
2349+
oneway=True,
23242350
)
23252351
nodes, edges = ox.graph_to_gdfs(graph)
23262352

tests/test_morphology.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,48 @@ def test_include_unenclosed_buildings_is_opt_in(
431431
assert len(opt_in_nodes["place"]) == 2
432432
assert "fallback_1" in opt_in_nodes["place"].index
433433

434+
def test_keep_buildings_with_fallback_and_multi_building_cell(
435+
self,
436+
sample_crs: str,
437+
monkeypatch: pytest.MonkeyPatch,
438+
) -> None:
439+
"""Fallback cells coexist with cells containing several buildings."""
440+
buildings = gpd.GeoDataFrame(
441+
{"building_id": ["outer", "nested", "unenclosed"]},
442+
geometry=[
443+
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
444+
Polygon([(0.25, 0.25), (0.75, 0.25), (0.75, 0.75), (0.25, 0.75)]),
445+
Polygon([(10, 0), (11, 0), (11, 1), (10, 1)]),
446+
],
447+
crs=sample_crs,
448+
)
449+
segments = gpd.GeoDataFrame(
450+
geometry=[LineString([(-1, -1), (2, -1)])],
451+
crs=sample_crs,
452+
)
453+
454+
monkeypatch.setattr(
455+
"city2graph.morphology.create_tessellation",
456+
_enclosed_cell_or_fail_on_fallback,
457+
)
458+
monkeypatch.setattr(
459+
"city2graph.morphology._filter_adjacent_tessellation",
460+
_passthrough_adjacent_filter,
461+
)
462+
463+
nodes, _ = morphological_graph(
464+
buildings,
465+
segments,
466+
keep_buildings=True,
467+
include_unenclosed_buildings=True,
468+
)
469+
470+
place_ids = list(nodes["place"].index)
471+
# The enclosed cell holds both overlapping buildings; the fallback cell
472+
# for the unenclosed building is matched exactly once to its source.
473+
assert place_ids.count("fallback_2") == 1
474+
assert place_ids.count("enclosed") == 2
475+
434476
def test_morphological_graph_passes_tessellation_limit(
435477
self,
436478
sample_crs: str,

tests/test_utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,57 @@ def fake_enclosed_tessellation(**kwargs: object) -> gpd.GeoDataFrame:
363363
assert result.geometry.area.sum() == pytest.approx(enclosure.area)
364364
assert "overlapping cells" in caplog.text
365365

366+
def test_enclosed_tessellation_overlap_retry_falls_back_to_simplify_false(
367+
self,
368+
sample_buildings_gdf: gpd.GeoDataFrame,
369+
sample_segments_gdf: gpd.GeoDataFrame,
370+
caplog: pytest.LogCaptureFixture,
371+
monkeypatch: pytest.MonkeyPatch,
372+
) -> None:
373+
"""A geometry-type error during the overlap retry should retry simplify=False."""
374+
enclosure = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
375+
monkeypatch.setattr(
376+
momepy,
377+
"enclosures",
378+
lambda **_kwargs: gpd.GeoDataFrame(
379+
{"eID": [0]},
380+
geometry=[enclosure],
381+
crs=sample_buildings_gdf.crs,
382+
),
383+
)
384+
385+
sane_cells = [
386+
Polygon([(0, 0), (5, 0), (5, 10), (0, 10)]),
387+
Polygon([(5, 0), (10, 0), (10, 10), (5, 10)]),
388+
]
389+
390+
def fake_enclosed_tessellation(**kwargs: object) -> gpd.GeoDataFrame:
391+
# The first run silently degenerates; the coarser grid_size retry
392+
# snaps cells into non-polygonal geometry and blows up in
393+
# coverage_simplify; only simplify=False succeeds.
394+
if kwargs.get("grid_size") and kwargs.get("simplify") is not False:
395+
msg = "One of the Geometry inputs is of incorrect geometry type."
396+
raise TypeError(msg)
397+
cells = sane_cells if kwargs.get("grid_size") else [enclosure, enclosure]
398+
return gpd.GeoDataFrame(
399+
{"enclosure_index": [0, 0]},
400+
geometry=cells,
401+
index=[0, 1],
402+
crs=sample_buildings_gdf.crs,
403+
)
404+
405+
monkeypatch.setattr(momepy, "enclosed_tessellation", fake_enclosed_tessellation)
406+
407+
with caplog.at_level("WARNING"):
408+
result = utils.create_tessellation(
409+
sample_buildings_gdf,
410+
primary_barriers=sample_segments_gdf,
411+
)
412+
413+
assert len(result) == 2
414+
assert result.geometry.area.sum() == pytest.approx(enclosure.area)
415+
assert "retrying with simplify=False" in caplog.text
416+
366417
def test_enclosed_tessellation_drops_persistently_overlapping_enclosures(
367418
self,
368419
sample_buildings_gdf: gpd.GeoDataFrame,

0 commit comments

Comments
 (0)