Skip to content

Commit 9fa27b6

Browse files
committed
Enhance options for morphological_graph()
1 parent dcfce4e commit 9fa27b6

2 files changed

Lines changed: 669 additions & 169 deletions

File tree

city2graph/morphology.py

Lines changed: 297 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ def morphological_graph( # noqa: PLR0913
147147
include_unenclosed_buildings: bool = False,
148148
as_nx: bool = False,
149149
duplicate_edges: bool = False,
150+
non_movement_barrier_col: str | None = None,
151+
tessellation_fallback: bool = False,
152+
tessellation_n_jobs: int = -1,
150153
) -> tuple[dict[str, gpd.GeoDataFrame], dict[tuple[str, str, str], gpd.GeoDataFrame]] | nx.Graph:
151154
"""
152155
Create a morphological graph from buildings and street segments.
@@ -192,6 +195,10 @@ def morphological_graph( # noqa: PLR0913
192195
primary_barrier_col : str, optional
193196
Column name containing alternative geometry for movement spaces. If specified and exists,
194197
this geometry will be used instead of the main geometry column for tessellation barriers.
198+
This only *substitutes the geometry* of a segment; it never removes a row from the movement
199+
layer, so every segment it applies to still becomes a movement node. To make individual rows
200+
act as barriers only (excluded from the movement layer), use ``non_movement_barrier_col``,
201+
which is an orthogonal setting.
195202
contiguity : str, default="queen"
196203
Type of spatial contiguity for place-to-place connections.
197204
Must be either "queen" or "rook".
@@ -218,6 +225,28 @@ def morphological_graph( # noqa: PLR0913
218225
("place", "faced_to", "movement") edges are left unchanged because a
219226
reverse row would mix movement ids into the place index level.
220227
Incompatible with ``as_nx=True``.
228+
non_movement_barrier_col : str, optional
229+
Name of a boolean column in ``segments_gdf`` flagging rows that act as
230+
barriers only. Flagged segments contribute to the tessellation barriers
231+
(clipped to the same radius as the buffered movement network) but are
232+
excluded from the movement nodes, the movement-to-movement graph and the
233+
network-distance computation. Rows where the column is missing or false
234+
are treated as ordinary movement segments. When None (default) every
235+
segment is a movement segment, preserving the previous behaviour.
236+
Whereas ``primary_barrier_col`` selects *which geometry* a segment uses,
237+
this flag decides *whether a row becomes a movement node at all*; the two
238+
settings are orthogonal.
239+
tessellation_fallback : bool, default=False
240+
If True and the enclosed tessellation encloses no area (``momepy`` raises
241+
``"No objects to concatenate"``) or yields no cells while buildings and
242+
reachable segments exist, fall back to using the reachable building
243+
footprints themselves as place cells. When False (default) the enclosed
244+
tessellation result is returned unchanged, preserving the previous
245+
behaviour (the underlying error is propagated).
246+
tessellation_n_jobs : int, default=-1
247+
Number of parallel jobs forwarded to the enclosed tessellation. Use
248+
``1`` to run serially, which avoids oversubscription when this function
249+
is itself called inside an outer parallel loop.
221250
222251
Returns
223252
-------
@@ -305,8 +334,17 @@ def morphological_graph( # noqa: PLR0913
305334
segments_gdf = segments_gdf.copy()
306335
segments_gdf[movement_id_col] = segments_gdf.index
307336

308-
# Compute the single-source reachability cost field once on the full network so that
309-
# streets, buildings and cells are all judged against the same metric on the same network.
337+
# Split out barrier-only segments so they shape the tessellation barriers
338+
# without ever becoming movement nodes or entering the reachability network.
339+
barrier_segments = None
340+
if non_movement_barrier_col and non_movement_barrier_col in segments_gdf.columns:
341+
barrier_mask = segments_gdf[non_movement_barrier_col].fillna(value=False).astype(bool)
342+
barrier_segments = segments_gdf.loc[barrier_mask].copy()
343+
segments_gdf = segments_gdf.loc[~barrier_mask].copy()
344+
345+
# Compute the single-source reachability cost field once on the movement network so
346+
# that streets, buildings and cells are all judged against the same metric on the
347+
# same network.
310348
reachability_field: _ReachabilityField | None = None
311349
if center_point is not None and distance is not None and not segments_gdf.empty:
312350
_, full_segment_edges = segments_to_graph(segments_gdf)
@@ -320,6 +358,17 @@ def morphological_graph( # noqa: PLR0913
320358
field=reachability_field,
321359
)
322360

361+
# Barrier-only segments enrich the tessellation context only; they never
362+
# reach the movement layers built from ``segments_filtered``.
363+
segments_buffer = _append_barrier_context_segments(
364+
segments_buffer,
365+
barrier_segments,
366+
center_point,
367+
distance,
368+
clipping_buffer,
369+
primary_barrier_col,
370+
)
371+
323372
tessellation = _create_and_filter_tessellation(
324373
buildings_gdf,
325374
segments_buffer,
@@ -334,6 +383,8 @@ def morphological_graph( # noqa: PLR0913
334383
extent_buffer,
335384
limit=limit,
336385
field=reachability_field,
386+
tessellation_fallback=tessellation_fallback,
387+
n_jobs=tessellation_n_jobs,
337388
)
338389

339390
# When a reachability budget is applied, prune place cells that face no
@@ -1331,6 +1382,8 @@ def _create_and_filter_tessellation( # noqa: PLR0913
13311382
extent_buffer: float = math.inf,
13321383
limit: gpd.GeoDataFrame | gpd.GeoSeries | BaseGeometry | None = None,
13331384
field: _ReachabilityField | None = None,
1385+
tessellation_fallback: bool = False,
1386+
n_jobs: int = -1,
13341387
) -> gpd.GeoDataFrame:
13351388
"""
13361389
Create tessellation and apply spatial filters.
@@ -1376,6 +1429,17 @@ def _create_and_filter_tessellation( # noqa: PLR0913
13761429
provided it is reused for both the building and tessellation network
13771430
filters so they share a single metric; when ``None`` each filter computes
13781431
its own field from the segments it is given.
1432+
tessellation_fallback : bool, default False
1433+
If True, fall back to reachable building footprints as place cells when
1434+
the enclosed tessellation encloses no area (``momepy`` raises
1435+
``"No objects to concatenate"``) or yields no cells while buildings and
1436+
reachable segments exist. When False the enclosed tessellation result is
1437+
returned unchanged and any underlying error is propagated.
1438+
n_jobs : int, default -1
1439+
Number of parallel jobs forwarded to `create_tessellation` (and in turn
1440+
`momepy.enclosed_tessellation`). Use ``1`` to run serially, which avoids
1441+
oversubscription when this function is itself called inside an outer
1442+
parallel loop.
13791443
13801444
Returns
13811445
-------
@@ -1387,11 +1451,30 @@ def _create_and_filter_tessellation( # noqa: PLR0913
13871451
tessellation_kwargs = {}
13881452
if limit is not None:
13891453
tessellation_kwargs["limit"] = limit
1390-
tessellation = create_tessellation(
1391-
buildings_gdf,
1392-
primary_barriers=None if barriers.empty else barriers, # Use barriers if available
1393-
**tessellation_kwargs,
1394-
)
1454+
if n_jobs != -1:
1455+
tessellation_kwargs["n_jobs"] = n_jobs
1456+
try:
1457+
tessellation = create_tessellation(
1458+
buildings_gdf,
1459+
primary_barriers=None if barriers.empty else barriers, # Use barriers if available
1460+
**tessellation_kwargs,
1461+
)
1462+
except ValueError as exc:
1463+
# momepy raises "No objects to concatenate" when the barriers enclose no
1464+
# area. With the fallback enabled, use building footprints as cells
1465+
# instead of propagating the error.
1466+
if not (tessellation_fallback and str(exc) == "No objects to concatenate"):
1467+
raise
1468+
return _fallback_tessellation_without_enclosures(
1469+
buildings_gdf,
1470+
segments_filtered,
1471+
center_point,
1472+
distance,
1473+
place_id_col,
1474+
extent_buffer,
1475+
keep_buildings,
1476+
field=field,
1477+
)
13951478

13961479
tessellation = tessellation.rename(columns={"tess_id": place_id_col})
13971480

@@ -1443,6 +1526,25 @@ def _create_and_filter_tessellation( # noqa: PLR0913
14431526
if keep_buildings:
14441527
tessellation = _add_building_info(tessellation, eligible_buildings)
14451528

1529+
# When the enclosed tessellation retained nothing despite usable inputs, fall
1530+
# back to building footprints so the morphology graph still has place cells.
1531+
if (
1532+
tessellation_fallback
1533+
and tessellation.empty
1534+
and not buildings_gdf.empty
1535+
and not segments_filtered.empty
1536+
):
1537+
return _fallback_tessellation_without_enclosures(
1538+
buildings_gdf,
1539+
segments_filtered,
1540+
center_point,
1541+
distance,
1542+
place_id_col,
1543+
extent_buffer,
1544+
keep_buildings,
1545+
field=field,
1546+
)
1547+
14461548
return tessellation
14471549

14481550

@@ -1548,6 +1650,122 @@ def _filter_buildings_by_network_distance(
15481650
return buildings_gdf.iloc[keep_ilocs].copy()
15491651

15501652

1653+
def _valid_polygon_gdf(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
1654+
"""
1655+
Return only valid polygonal rows, repairing self-intersections in place.
1656+
1657+
Rows whose geometry is missing, empty or not a (Multi)Polygon are dropped.
1658+
Remaining invalid polygons are repaired with a zero-width buffer and
1659+
re-validated, so the result contains only usable polygon footprints.
1660+
1661+
Parameters
1662+
----------
1663+
gdf : geopandas.GeoDataFrame
1664+
Candidate polygon geometries (typically building footprints).
1665+
1666+
Returns
1667+
-------
1668+
geopandas.GeoDataFrame
1669+
The valid polygonal subset of ``gdf``.
1670+
"""
1671+
out = gdf.copy()
1672+
valid_geom = out.geometry.notna() & ~out.geometry.is_empty
1673+
valid_geom = valid_geom & out.geometry.geom_type.isin(["Polygon", "MultiPolygon"])
1674+
out = out.loc[valid_geom].copy()
1675+
if out.empty:
1676+
return out
1677+
1678+
invalid = ~out.geometry.is_valid
1679+
if invalid.any():
1680+
out.loc[invalid, out.geometry.name] = out.loc[invalid].geometry.buffer(0)
1681+
valid_geom = out.geometry.notna() & ~out.geometry.is_empty & out.geometry.is_valid
1682+
valid_geom = valid_geom & out.geometry.geom_type.isin(["Polygon", "MultiPolygon"])
1683+
out = out.loc[valid_geom].copy()
1684+
return out
1685+
1686+
1687+
def _fallback_tessellation_without_enclosures(
1688+
buildings_gdf: gpd.GeoDataFrame,
1689+
segments_filtered: gpd.GeoDataFrame,
1690+
center_point: gpd.GeoSeries | gpd.GeoDataFrame | Point | None,
1691+
distance: float | None,
1692+
place_id_col: str,
1693+
extent_buffer: float,
1694+
keep_buildings: bool,
1695+
field: _ReachabilityField | None = None,
1696+
) -> gpd.GeoDataFrame:
1697+
"""
1698+
Build place cells from building footprints when enclosed tessellation fails.
1699+
1700+
Used as a fallback when the enclosed tessellation encloses no area or yields
1701+
no cells. Each reachable building footprint becomes its own place cell, tagged
1702+
with a synthetic ``fallback_`` place id and a single ``"fallback"`` enclosure,
1703+
and is judged against the same reachability budget (network ``distance`` plus
1704+
``extent_buffer`` access cap) as the primary path so retention rules match.
1705+
1706+
Parameters
1707+
----------
1708+
buildings_gdf : geopandas.GeoDataFrame
1709+
Building footprints used as fallback cells.
1710+
segments_filtered : geopandas.GeoDataFrame
1711+
Reachable movement segments used for the network-distance filters.
1712+
center_point : geopandas.GeoSeries or geopandas.GeoDataFrame or shapely.geometry.Point or None
1713+
Centre for the reachability budget. Distance filters are skipped when None.
1714+
distance : float or None
1715+
Maximum network distance for a cell to be retained.
1716+
place_id_col : str
1717+
Name of the place ID column.
1718+
extent_buffer : float
1719+
Maximum perpendicular access distance from a street to a cell centroid.
1720+
keep_buildings : bool
1721+
If True, attach building information via ``building_geometry``.
1722+
field : _ReachabilityField or None, optional
1723+
Shared reachability cost field reused for the network-distance filters.
1724+
1725+
Returns
1726+
-------
1727+
geopandas.GeoDataFrame
1728+
Fallback tessellation cells, possibly empty when nothing is reachable.
1729+
"""
1730+
buildings = _valid_polygon_gdf(buildings_gdf)
1731+
if buildings.empty:
1732+
return buildings.iloc[0:0].copy()
1733+
1734+
# Retention uses the reachable isochrone streets and the bounded access cap,
1735+
# matching the primary path so fallback cells obey the same acceptance rule.
1736+
buildings = _filter_buildings_by_network_distance(
1737+
buildings,
1738+
segments_filtered,
1739+
center_point,
1740+
distance,
1741+
extent_buffer,
1742+
field=field,
1743+
)
1744+
if buildings.empty:
1745+
return buildings.iloc[0:0].copy()
1746+
1747+
tessellation = gpd.GeoDataFrame(
1748+
{
1749+
place_id_col: "fallback_" + buildings.index.astype(str),
1750+
"enclosure_index": "fallback",
1751+
},
1752+
geometry=buildings.geometry.to_numpy(),
1753+
crs=buildings.crs,
1754+
)
1755+
if center_point is not None and distance is not None:
1756+
tessellation = _filter_tessellation_by_network_distance(
1757+
tessellation,
1758+
segments_filtered,
1759+
center_point,
1760+
distance,
1761+
extent_buffer,
1762+
field=field,
1763+
)
1764+
if keep_buildings:
1765+
tessellation = _add_building_info(tessellation, buildings)
1766+
return tessellation
1767+
1768+
15511769
def _buildings_without_tessellation(
15521770
tessellation: gpd.GeoDataFrame,
15531771
buildings_gdf: gpd.GeoDataFrame,
@@ -1772,6 +1990,78 @@ def _prepare_barriers(
17721990
return segments.copy()
17731991

17741992

1993+
def _append_barrier_context_segments(
1994+
segments_buffer: gpd.GeoDataFrame,
1995+
barrier_segments: gpd.GeoDataFrame | None,
1996+
center_point: gpd.GeoSeries | gpd.GeoDataFrame | Point | None,
1997+
distance: float | None,
1998+
clipping_buffer: float,
1999+
primary_barrier_col: str | None,
2000+
) -> gpd.GeoDataFrame:
2001+
"""
2002+
Merge barrier-only segments into the tessellation context buffer.
2003+
2004+
Barrier-only rows shape the tessellation barriers but are never movement
2005+
nodes, so they are added to ``segments_buffer`` (which provides tessellation
2006+
context only) rather than to the filtered movement segments. They are first
2007+
clipped to the same radius used for the buffered movement network so distant
2008+
barriers do not enlarge the tessellation extent.
2009+
2010+
Parameters
2011+
----------
2012+
segments_buffer : geopandas.GeoDataFrame
2013+
Buffered movement segments used as tessellation context.
2014+
barrier_segments : geopandas.GeoDataFrame or None
2015+
Barrier-only segments to merge in. When None or empty the buffer is
2016+
returned unchanged.
2017+
center_point : geopandas.GeoSeries or geopandas.GeoDataFrame or shapely.geometry.Point or None
2018+
Centre used to clip barriers by radius. Clipping is skipped when None.
2019+
distance : float or None
2020+
Network-distance budget. Clipping is skipped when None.
2021+
clipping_buffer : float
2022+
Buffer added to ``distance`` to obtain the clipping radius. When infinite
2023+
the radius falls back to ``distance``.
2024+
primary_barrier_col : str or None
2025+
Name of the alternative barrier-geometry column. When present in the
2026+
buffer it is populated on the appended barriers (from their geometry) so
2027+
:func:`_prepare_barriers` reads both barrier sets from one column.
2028+
2029+
Returns
2030+
-------
2031+
geopandas.GeoDataFrame
2032+
The buffer with barrier-only segments appended, or the unchanged buffer.
2033+
"""
2034+
if barrier_segments is None or barrier_segments.empty:
2035+
return segments_buffer
2036+
2037+
barriers = barrier_segments
2038+
if center_point is not None and distance is not None:
2039+
radius = distance if math.isinf(clipping_buffer) else distance + clipping_buffer
2040+
center_geom = _extract_center_geometry(center_point)
2041+
barriers = barriers.loc[barriers.geometry.distance(center_geom) <= radius]
2042+
if barriers.empty:
2043+
return segments_buffer
2044+
2045+
barriers = barriers.copy()
2046+
geometry_name = segments_buffer.geometry.name
2047+
if barriers.geometry.name != geometry_name:
2048+
barriers = barriers.rename_geometry(geometry_name)
2049+
if (
2050+
primary_barrier_col
2051+
and primary_barrier_col != geometry_name
2052+
and primary_barrier_col in segments_buffer.columns
2053+
and primary_barrier_col not in barriers.columns
2054+
):
2055+
barriers[primary_barrier_col] = barriers.geometry
2056+
2057+
merged = (
2058+
barriers
2059+
if segments_buffer.empty
2060+
else pd.concat([segments_buffer, barriers], ignore_index=False, sort=False)
2061+
)
2062+
return gpd.GeoDataFrame(merged, geometry=geometry_name, crs=segments_buffer.crs)
2063+
2064+
17752065
def _add_building_info(
17762066
tessellation: gpd.GeoDataFrame,
17772067
buildings: gpd.GeoDataFrame,

0 commit comments

Comments
 (0)