@@ -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+
15511769def _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+
17752065def _add_building_info (
17762066 tessellation : gpd .GeoDataFrame ,
17772067 buildings : gpd .GeoDataFrame ,
0 commit comments