ObjectNat 2.2.0__cp311-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. objectnat/__init__.py +9 -0
  2. objectnat/_api.py +29 -0
  3. objectnat/_config.py +42 -0
  4. objectnat/_rust.pyd +0 -0
  5. objectnat/_version.py +1 -0
  6. objectnat/methods/__init__.py +0 -0
  7. objectnat/methods/accessibility/__init__.py +3 -0
  8. objectnat/methods/accessibility/_utils.py +396 -0
  9. objectnat/methods/accessibility/coverage.py +317 -0
  10. objectnat/methods/accessibility/isochrones.py +376 -0
  11. objectnat/methods/accessibility/radius.py +42 -0
  12. objectnat/methods/insolation/__init__.py +1 -0
  13. objectnat/methods/insolation/_sun.py +86 -0
  14. objectnat/methods/insolation/insolation.py +389 -0
  15. objectnat/methods/noise/__init__.py +4 -0
  16. objectnat/methods/noise/noise_grid.py +430 -0
  17. objectnat/methods/noise/noise_init_data.py +10 -0
  18. objectnat/methods/noise/noise_reduce.py +155 -0
  19. objectnat/methods/noise/noise_simulation.py +605 -0
  20. objectnat/methods/noise/noise_simulation_simplified.py +255 -0
  21. objectnat/methods/provision/__init__.py +7 -0
  22. objectnat/methods/provision/_matrix.py +61 -0
  23. objectnat/methods/provision/provision.py +101 -0
  24. objectnat/methods/provision/provision_calculating.py +267 -0
  25. objectnat/methods/provision/provision_exceptions.py +67 -0
  26. objectnat/methods/provision/provision_result.py +177 -0
  27. objectnat/methods/utils/__init__.py +0 -0
  28. objectnat/methods/utils/geom_utils.py +144 -0
  29. objectnat/methods/utils/raster.py +351 -0
  30. objectnat/methods/utils/scene.py +228 -0
  31. objectnat/methods/visibility/__init__.py +3 -0
  32. objectnat/methods/visibility/_native.py +153 -0
  33. objectnat/methods/visibility/catchment.py +212 -0
  34. objectnat/methods/visibility/isovist.py +176 -0
  35. objectnat/methods/visibility/viewshed.py +341 -0
  36. objectnat/methods/visibility/visibility_analysis.py +272 -0
  37. objectnat/py.typed +0 -0
  38. objectnat-2.2.0.dist-info/METADATA +232 -0
  39. objectnat-2.2.0.dist-info/RECORD +42 -0
  40. objectnat-2.2.0.dist-info/WHEEL +4 -0
  41. objectnat-2.2.0.dist-info/licenses/LICENSE.txt +28 -0
  42. objectnat-2.2.0.dist-info/sboms/objectnat-rs.cyclonedx.json +1105 -0
objectnat/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ ObjectNat is an open-source library created for geospatial analysis created by IDU team.
3
+
4
+ Homepage https://github.com/IDUclub/ObjectNat.
5
+ """
6
+
7
+ from ._config import config
8
+ from ._api import *
9
+ from ._version import VERSION as __version__
objectnat/_api.py ADDED
@@ -0,0 +1,29 @@
1
+ # pylint: disable=unused-import,wildcard-import,unused-wildcard-import
2
+
3
+ from .methods.accessibility import (
4
+ get_graph_coverage,
5
+ get_graph_isochrones,
6
+ get_radius_coverage,
7
+ get_stepped_graph_coverage,
8
+ get_stepped_graph_isochrones,
9
+ )
10
+ from .methods.insolation import get_facade_insolation, get_insolation
11
+ from .methods.noise import calculate_simplified_noise_frame, simulate_noise, simulate_noise_grid
12
+ from .methods.provision import (
13
+ ProvisionResult,
14
+ clip_provision,
15
+ get_provision_buildings,
16
+ get_provision_links,
17
+ get_provision_services,
18
+ get_service_provision,
19
+ recalculate_links,
20
+ )
21
+ from .methods.utils.raster import Raster, rasterize
22
+ from .methods.visibility import (
23
+ get_isovist_grid,
24
+ get_observer_points,
25
+ get_viewshed,
26
+ get_viewshed_catchment,
27
+ get_visibility,
28
+ get_visibility_catchment,
29
+ )
objectnat/_config.py ADDED
@@ -0,0 +1,42 @@
1
+ import sys
2
+ from typing import Literal
3
+
4
+ from loguru import logger
5
+
6
+
7
+ class Config:
8
+ """
9
+ Global settings of ObjectNat: the logging level and the progress bars.
10
+
11
+ Attributes
12
+ ----------
13
+ enable_tqdm_bar : bool
14
+ Enables or disables progress bars (via tqdm). Defaults to True.
15
+ logger : Logger
16
+ Logging instance to handle application logging.
17
+
18
+ Methods
19
+ -------
20
+ change_logger_lvl(lvl: Literal["TRACE", "DEBUG", "INFO", "WARN", "ERROR"])
21
+ Changes the logging level to the specified value.
22
+ set_enable_tqdm(enable: bool)
23
+ Enables or disables progress bars in the application.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ enable_tqdm_bar=True,
29
+ ):
30
+ self.enable_tqdm_bar = enable_tqdm_bar
31
+ self.logger = logger
32
+
33
+ def change_logger_lvl(self, lvl: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]):
34
+ self.logger.remove()
35
+ self.logger.add(sys.stderr, level=lvl)
36
+
37
+ def set_enable_tqdm(self, enable: bool):
38
+ self.enable_tqdm_bar = enable
39
+
40
+
41
+ config = Config()
42
+ config.change_logger_lvl("WARNING")
objectnat/_rust.pyd ADDED
Binary file
objectnat/_version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "2.2.0"
File without changes
@@ -0,0 +1,3 @@
1
+ from .coverage import get_graph_coverage, get_stepped_graph_coverage
2
+ from .isochrones import get_graph_isochrones, get_stepped_graph_isochrones
3
+ from .radius import get_radius_coverage
@@ -0,0 +1,396 @@
1
+ from typing import Any, Literal
2
+
3
+ import geopandas as gpd
4
+ import numpy as np
5
+ import pandas as pd
6
+ import shapely
7
+ from iduedu import UrbanGraph, multi_source_dijkstra
8
+ from shapely import convex_hull
9
+ from shapely.geometry import GeometryCollection
10
+
11
+ from objectnat.methods.utils.geom_utils import overlay_by_value, remove_inner_geom
12
+
13
+ SteppedGeometryType = Literal["radius", "ways", "separate"] | None
14
+ _WALK_ONLY_GRAPH_TYPES = {"intermodal", "walk"}
15
+
16
+
17
+ def check_buffer_args(buffer_factor: float, road_buffer_size: float, off_network_distance: float | None = None) -> None:
18
+ """Validate the buffer arguments shared by the accessibility functions."""
19
+ if buffer_factor <= 0:
20
+ raise ValueError(f"buffer_factor must be > 0, got {buffer_factor}")
21
+ if road_buffer_size <= 0:
22
+ raise ValueError(f"road_buffer_size must be > 0, got {road_buffer_size}")
23
+ if off_network_distance is not None and not off_network_distance > 0:
24
+ raise ValueError(f"off_network_distance must be > 0 or None, got {off_network_distance}")
25
+
26
+
27
+ def node_points(urban_graph: UrbanGraph, table: pd.DataFrame) -> gpd.GeoDataFrame:
28
+ """Graph node points for the rows of ``table`` (indexed by node id), with its columns."""
29
+ return urban_graph.nodes_gdf.loc[pd.Index(table.index), ["geometry"]].join(table, how="left")
30
+
31
+
32
+ def stepped_accessibility(
33
+ urban_graph: UrbanGraph,
34
+ *,
35
+ source_nodes,
36
+ gdf_sources,
37
+ graph_node_column: str,
38
+ weight_type: str,
39
+ weight_value_cutoff: float | None,
40
+ step: float | None,
41
+ geometry_type: SteppedGeometryType,
42
+ zone,
43
+ buffer_factor: float,
44
+ road_buffer_size: float,
45
+ off_network_distance: float | None,
46
+ reverse: bool,
47
+ ) -> gpd.GeoDataFrame:
48
+ """
49
+ Bands of width ``step`` of the cost to the nearest source: stepped isochrones search from the origins
50
+ (``reverse=False``), stepped coverage towards the services (``reverse=True``).
51
+ """
52
+ if geometry_type is not None and geometry_type not in {"radius", "ways", "separate"}:
53
+ raise ValueError(f"Unsupported geometry_type={geometry_type!r}")
54
+ if step is None:
55
+ step = 100.0 if weight_type == "length_meter" else 1.0
56
+ if step <= 0:
57
+ raise ValueError(f"step must be > 0, got {step}")
58
+ check_buffer_args(buffer_factor, road_buffer_size, off_network_distance)
59
+
60
+ distances = multi_source_dijkstra(
61
+ urban_graph,
62
+ source_nodes=source_nodes,
63
+ gdf_sources=gdf_sources,
64
+ graph_node_column=graph_node_column,
65
+ weight=weight_type,
66
+ cutoff=weight_value_cutoff,
67
+ reverse=reverse,
68
+ output="distances",
69
+ ).sparse.to_dense()
70
+ reachable = distances < np.inf
71
+ reachable_nodes = pd.DataFrame(
72
+ {"dist": distances[reachable].astype(float)}, index=pd.Index(distances.index[reachable], name="node")
73
+ )
74
+ if reachable_nodes.empty:
75
+ return gpd.GeoDataFrame()
76
+
77
+ weight_value = float(reachable_nodes["dist"].max()) if weight_value_cutoff is None else float(weight_value_cutoff)
78
+ result = build_stepped_accessibility_geometry(
79
+ urban_graph,
80
+ node_points(urban_graph, reachable_nodes),
81
+ geometry_type=geometry_type,
82
+ weight_value=weight_value,
83
+ weight_type=weight_type,
84
+ step=float(step),
85
+ local_crs=urban_graph.nodes_gdf.crs,
86
+ zone=zone,
87
+ buffer_factor=float(buffer_factor),
88
+ road_buffer_size=float(road_buffer_size),
89
+ off_network_distance=off_network_distance,
90
+ )
91
+ original_crs = gdf_sources.crs if gdf_sources is not None else urban_graph.nodes_gdf.crs
92
+ return result.to_crs(original_crs)
93
+
94
+
95
+ def geometry_edges(urban_graph: UrbanGraph) -> gpd.GeoDataFrame:
96
+ """Return the edges that shape accessibility geometry.
97
+
98
+ Walk and intermodal graphs use their pedestrian (``type == "walk"``) edges; other
99
+ graphs, or graphs without a ``type`` column, use every edge.
100
+ """
101
+ edges = urban_graph.edges_gdf
102
+ if urban_graph.type in _WALK_ONLY_GRAPH_TYPES and "type" in edges.columns:
103
+ walk_edges = edges[edges["type"] == "walk"]
104
+ if not walk_edges.empty:
105
+ return walk_edges
106
+ return edges
107
+
108
+
109
+ def select_geometry_nodes(urban_graph: UrbanGraph, reachable_graph_nodes_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
110
+ """Keep the reachable nodes that shape geometry, one per location.
111
+
112
+ Only endpoints of :func:`geometry_edges` are kept, unless none of them is reachable,
113
+ and nodes sharing a location are collapsed onto the smallest ``dist``. Several route
114
+ nodes can sit on one stop; a Voronoi cell shared by them would otherwise be assigned
115
+ to every band they fall in.
116
+ """
117
+ edges = geometry_edges(urban_graph)
118
+ nodes = reachable_graph_nodes_gdf
119
+ on_geometry_edges = nodes.index.isin(edges["u"]) | nodes.index.isin(edges["v"])
120
+ if on_geometry_edges.any():
121
+ nodes = nodes.loc[on_geometry_edges]
122
+ ordered = nodes.iloc[np.argsort(nodes["dist"].to_numpy(dtype=float), kind="stable")]
123
+ location = pd.MultiIndex.from_arrays([ordered.geometry.x.to_numpy(), ordered.geometry.y.to_numpy()])
124
+ return ordered.iloc[np.flatnonzero(~location.duplicated())]
125
+
126
+
127
+ def build_voronoi_cells(
128
+ urban_graph: UrbanGraph,
129
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
130
+ *,
131
+ value_column: str,
132
+ local_crs: Any,
133
+ clip_geom=None,
134
+ ) -> gpd.GeoDataFrame:
135
+ reachable_node_index = pd.Index(reachable_graph_nodes_gdf.index)
136
+ edges = geometry_edges(urban_graph)
137
+ if clip_geom is None:
138
+ edges_near_reachable = edges[edges["u"].isin(reachable_node_index) | edges["v"].isin(reachable_node_index)]
139
+ neighbor_nodes = pd.Index(edges_near_reachable["u"]).append(pd.Index(edges_near_reachable["v"])).unique()
140
+ boundary_nodes = neighbor_nodes.difference(reachable_node_index)
141
+ voronoi_node_index = reachable_node_index.append(boundary_nodes).unique()
142
+ else:
143
+ nodes = urban_graph.nodes_gdf
144
+ site_mask = (nodes.index.isin(edges["u"]) | nodes.index.isin(edges["v"])) & nodes.geometry.intersects(clip_geom)
145
+ voronoi_node_index = nodes.index[site_mask].append(reachable_node_index).unique()
146
+ voronoi_nodes = urban_graph.nodes_gdf.loc[voronoi_node_index, ["geometry"]]
147
+ voronois = gpd.GeoDataFrame(geometry=list(voronoi_nodes.geometry.voronoi_polygons()), crs=local_crs)
148
+ reachable_cell_index = reachable_graph_nodes_gdf.reset_index(names="node").sjoin(voronois, how="inner")
149
+ return gpd.GeoDataFrame(
150
+ reachable_cell_index[["node", value_column]].reset_index(drop=True),
151
+ geometry=voronois.geometry.iloc[reachable_cell_index["index_right"].to_numpy()].reset_index(drop=True),
152
+ crs=local_crs,
153
+ )
154
+
155
+
156
+ def edge_speed_m_per_min(urban_graph: UrbanGraph) -> float:
157
+ edge_speeds = urban_graph.edges_gdf["length_meter"] / urban_graph.edges_gdf["time_min"]
158
+ edge_speeds = edge_speeds.replace([np.inf, -np.inf], np.nan).dropna()
159
+ edge_speeds = edge_speeds[edge_speeds > 0]
160
+ if edge_speeds.empty:
161
+ raise ValueError("Cannot calculate speed_m_per_min from edge length_meter/time_min")
162
+ return float(edge_speeds.median())
163
+
164
+
165
+ def radius_buffers(
166
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
167
+ *,
168
+ weight_value: float,
169
+ weight_type: str,
170
+ speed_m_per_min: float | None,
171
+ max_distance: float | None = None,
172
+ ) -> np.ndarray:
173
+ """
174
+ Buffers of the reachable nodes by their remaining budget as a distance (the parts of a radius isochrone),
175
+ at most ``max_distance`` metres: the budget left at a node is spent off the network only that far.
176
+ """
177
+ buffer_size = (float(weight_value) - reachable_graph_nodes_gdf["dist"].to_numpy(dtype=float)).clip(min=0.0)
178
+
179
+ if weight_type == "time_min":
180
+ if speed_m_per_min is None:
181
+ raise ValueError("speed_m_per_min is required for weight_type='time_min'")
182
+ buffer_size = buffer_size * float(speed_m_per_min)
183
+ if max_distance is not None:
184
+ buffer_size = np.minimum(buffer_size, float(max_distance))
185
+ return shapely.buffer(reachable_graph_nodes_gdf.geometry.values, buffer_size, quad_segs=4)
186
+
187
+
188
+ def build_radius_clip_geometry(
189
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
190
+ *,
191
+ weight_value: float,
192
+ weight_type: str,
193
+ speed_m_per_min: float | None,
194
+ max_distance: float | None = None,
195
+ ):
196
+ return shapely.union_all(
197
+ radius_buffers(
198
+ reachable_graph_nodes_gdf,
199
+ weight_value=weight_value,
200
+ weight_type=weight_type,
201
+ speed_m_per_min=speed_m_per_min,
202
+ max_distance=max_distance,
203
+ )
204
+ )
205
+
206
+
207
+ def ways_road_buffers(
208
+ urban_graph: UrbanGraph, reachable_graph_nodes_gdf: gpd.GeoDataFrame, road_buffer_size: float
209
+ ) -> np.ndarray:
210
+ """
211
+ Buffers of the geometry edges touching reachable nodes. Edges stored once per direction share
212
+ their geometry; each is kept once, which halves the union work without changing its result.
213
+ """
214
+ reachable_node_index = pd.Index(reachable_graph_nodes_gdf.index)
215
+ edges = geometry_edges(urban_graph)
216
+ edge_mask = edges["u"].isin(reachable_node_index) | edges["v"].isin(reachable_node_index)
217
+ lines = shapely.normalize(edges.geometry.values[edge_mask.to_numpy()])
218
+ if len(lines) == 0:
219
+ return lines
220
+ _, first = np.unique(shapely.to_wkb(lines), return_index=True)
221
+ return shapely.buffer(lines[np.sort(first)], float(road_buffer_size), quad_segs=1)
222
+
223
+
224
+ def finish_ways_clip_geometry(radius_geom, roads_geom, reachable_graph_nodes_gdf: gpd.GeoDataFrame, crs):
225
+ """Keep the parts of the road buffers inside the radius isochrone that hold a reachable node."""
226
+ if roads_geom is None or roads_geom.is_empty:
227
+ return GeometryCollection()
228
+ roads = gpd.GeoDataFrame(geometry=[roads_geom], crs=crs)
229
+ clipped_roads = roads.clip(radius_geom, keep_geom_type=True).explode(ignore_index=True)
230
+ if clipped_roads.empty:
231
+ return GeometryCollection()
232
+
233
+ geom_to_keep = clipped_roads.sjoin(reachable_graph_nodes_gdf[["geometry"]], how="inner").index.unique()
234
+ if len(geom_to_keep) == 0:
235
+ return GeometryCollection()
236
+ return remove_inner_geom(clipped_roads.loc[geom_to_keep].union_all())
237
+
238
+
239
+ def build_ways_clip_geometry(
240
+ urban_graph: UrbanGraph,
241
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
242
+ *,
243
+ weight_value: float,
244
+ weight_type: str,
245
+ speed_m_per_min: float | None,
246
+ road_buffer_size: float,
247
+ ):
248
+ radius_geom = build_radius_clip_geometry(
249
+ reachable_graph_nodes_gdf,
250
+ weight_value=weight_value,
251
+ weight_type=weight_type,
252
+ speed_m_per_min=speed_m_per_min,
253
+ )
254
+ road_buffers = ways_road_buffers(urban_graph, reachable_graph_nodes_gdf, road_buffer_size)
255
+ if len(road_buffers) == 0:
256
+ return GeometryCollection()
257
+ return finish_ways_clip_geometry(
258
+ radius_geom, shapely.union_all(road_buffers), reachable_graph_nodes_gdf, urban_graph.edges_gdf.crs
259
+ )
260
+
261
+
262
+ def build_separated_dist_polygons(
263
+ points: gpd.GeoDataFrame,
264
+ *,
265
+ weight_value: float,
266
+ weight_type: str,
267
+ step: float,
268
+ speed_m_per_min: float | None = None,
269
+ buffer_ratio: float = 0.7,
270
+ ) -> gpd.GeoDataFrame:
271
+ """
272
+ Build independent per-step buffer polygons over reachable graph nodes.
273
+
274
+ ``points`` must contain point geometry and a ``dist`` column in units of
275
+ ``weight_type``. For ``time_min`` the buffer size is converted to metres via
276
+ ``speed_m_per_min``.
277
+ """
278
+
279
+ def empty_result() -> gpd.GeoDataFrame:
280
+ return gpd.GeoDataFrame({"dist": []}, geometry=[], crs=points.crs)
281
+
282
+ if points.empty:
283
+ return empty_result()
284
+ if "dist" not in points.columns:
285
+ raise KeyError("points must contain 'dist' column")
286
+ if weight_value < 0:
287
+ raise ValueError(f"weight_value must be >= 0, got {weight_value}")
288
+ if step <= 0:
289
+ raise ValueError(f"step must be > 0, got {step}")
290
+ if weight_type == "time_min" and (speed_m_per_min is None or speed_m_per_min <= 0):
291
+ raise ValueError("speed_m_per_min must be positive for weight_type='time_min'")
292
+
293
+ points = points.loc[points["dist"].notna(), ["dist", points.geometry.name]].copy()
294
+ if points.empty:
295
+ return empty_result()
296
+
297
+ dist_for_buffer = points["dist"].astype(float).clip(lower=0.1)
298
+ upper_step = np.minimum((np.floor(dist_for_buffer / float(step)) + 1.0) * float(step), float(weight_value))
299
+ buffer_size = np.maximum(upper_step - dist_for_buffer, 0.0) * float(buffer_ratio)
300
+ if weight_type == "time_min":
301
+ buffer_size = buffer_size * float(speed_m_per_min)
302
+
303
+ # the first band is [0, step]: sources at cost 0 belong to it, not to a band of their own
304
+ points["dist"] = np.minimum(
305
+ np.maximum(np.ceil(points["dist"].astype(float).to_numpy() / float(step)), 1.0) * float(step),
306
+ float(weight_value),
307
+ )
308
+ points.geometry = points.geometry.buffer(buffer_size)
309
+ points = points[~points.geometry.is_empty]
310
+ if points.empty:
311
+ return empty_result()
312
+
313
+ # a face covered by several bands is reached first in the earliest of them
314
+ stepped = overlay_by_value(points.dissolve(by="dist", as_index=False), "dist", how="min")
315
+ if stepped.empty:
316
+ return empty_result()
317
+ return stepped.explode(ignore_index=True)
318
+
319
+
320
+ def build_stepped_accessibility_geometry(
321
+ urban_graph: UrbanGraph,
322
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
323
+ *,
324
+ geometry_type: SteppedGeometryType,
325
+ weight_value: float,
326
+ weight_type: str,
327
+ step: float,
328
+ local_crs: Any,
329
+ zone: gpd.GeoDataFrame | gpd.GeoSeries | None,
330
+ buffer_factor: float,
331
+ road_buffer_size: float,
332
+ off_network_distance: float | None = None,
333
+ ) -> gpd.GeoDataFrame:
334
+ if reachable_graph_nodes_gdf.empty:
335
+ return gpd.GeoDataFrame()
336
+
337
+ reachable_graph_nodes_gdf = select_geometry_nodes(urban_graph, reachable_graph_nodes_gdf)
338
+ speed_m_per_min = edge_speed_m_per_min(urban_graph) if weight_type == "time_min" else None
339
+
340
+ if geometry_type == "separate":
341
+ result = build_separated_dist_polygons(
342
+ reachable_graph_nodes_gdf,
343
+ weight_value=float(weight_value),
344
+ weight_type=weight_type,
345
+ step=float(step),
346
+ speed_m_per_min=speed_m_per_min,
347
+ buffer_ratio=buffer_factor,
348
+ )
349
+ if zone is not None and not result.empty:
350
+ result = result.clip(zone.to_crs(local_crs))
351
+ return result
352
+
353
+ if geometry_type is None:
354
+ if zone is None:
355
+ clip_geom = convex_hull(reachable_graph_nodes_gdf.union_all())
356
+ else:
357
+ clip_geom = zone.to_crs(local_crs).union_all()
358
+ elif geometry_type == "radius":
359
+ clip_geom = build_radius_clip_geometry(
360
+ reachable_graph_nodes_gdf,
361
+ weight_value=float(weight_value),
362
+ weight_type=weight_type,
363
+ speed_m_per_min=speed_m_per_min,
364
+ max_distance=off_network_distance,
365
+ )
366
+ elif geometry_type == "ways":
367
+ clip_geom = build_ways_clip_geometry(
368
+ urban_graph,
369
+ reachable_graph_nodes_gdf,
370
+ weight_value=float(weight_value),
371
+ weight_type=weight_type,
372
+ speed_m_per_min=speed_m_per_min,
373
+ road_buffer_size=road_buffer_size,
374
+ )
375
+ else:
376
+ raise ValueError(f"Unsupported geometry_type={geometry_type!r}")
377
+
378
+ if geometry_type is not None and zone is not None:
379
+ clip_geom = clip_geom.intersection(zone.to_crs(local_crs).union_all())
380
+
381
+ nodes_for_voronoi = reachable_graph_nodes_gdf.copy()
382
+ # the first band is [0, step]: sources at cost 0 belong to it, not to a band of their own
383
+ nodes_for_voronoi["dist"] = np.minimum(
384
+ np.maximum(np.ceil(nodes_for_voronoi["dist"].astype(float).to_numpy() / float(step)), 1.0) * float(step),
385
+ float(weight_value),
386
+ )
387
+
388
+ cells = build_voronoi_cells(
389
+ urban_graph,
390
+ nodes_for_voronoi,
391
+ value_column="dist",
392
+ local_crs=local_crs,
393
+ clip_geom=clip_geom if geometry_type is not None else None,
394
+ )
395
+ result = cells.dissolve(by="dist", as_index=False).drop(columns=["node"])
396
+ return result.clip(clip_geom)