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.
- objectnat/__init__.py +9 -0
- objectnat/_api.py +29 -0
- objectnat/_config.py +42 -0
- objectnat/_rust.pyd +0 -0
- objectnat/_version.py +1 -0
- objectnat/methods/__init__.py +0 -0
- objectnat/methods/accessibility/__init__.py +3 -0
- objectnat/methods/accessibility/_utils.py +396 -0
- objectnat/methods/accessibility/coverage.py +317 -0
- objectnat/methods/accessibility/isochrones.py +376 -0
- objectnat/methods/accessibility/radius.py +42 -0
- objectnat/methods/insolation/__init__.py +1 -0
- objectnat/methods/insolation/_sun.py +86 -0
- objectnat/methods/insolation/insolation.py +389 -0
- objectnat/methods/noise/__init__.py +4 -0
- objectnat/methods/noise/noise_grid.py +430 -0
- objectnat/methods/noise/noise_init_data.py +10 -0
- objectnat/methods/noise/noise_reduce.py +155 -0
- objectnat/methods/noise/noise_simulation.py +605 -0
- objectnat/methods/noise/noise_simulation_simplified.py +255 -0
- objectnat/methods/provision/__init__.py +7 -0
- objectnat/methods/provision/_matrix.py +61 -0
- objectnat/methods/provision/provision.py +101 -0
- objectnat/methods/provision/provision_calculating.py +267 -0
- objectnat/methods/provision/provision_exceptions.py +67 -0
- objectnat/methods/provision/provision_result.py +177 -0
- objectnat/methods/utils/__init__.py +0 -0
- objectnat/methods/utils/geom_utils.py +144 -0
- objectnat/methods/utils/raster.py +351 -0
- objectnat/methods/utils/scene.py +228 -0
- objectnat/methods/visibility/__init__.py +3 -0
- objectnat/methods/visibility/_native.py +153 -0
- objectnat/methods/visibility/catchment.py +212 -0
- objectnat/methods/visibility/isovist.py +176 -0
- objectnat/methods/visibility/viewshed.py +341 -0
- objectnat/methods/visibility/visibility_analysis.py +272 -0
- objectnat/py.typed +0 -0
- objectnat-2.2.0.dist-info/METADATA +232 -0
- objectnat-2.2.0.dist-info/RECORD +42 -0
- objectnat-2.2.0.dist-info/WHEEL +4 -0
- objectnat-2.2.0.dist-info/licenses/LICENSE.txt +28 -0
- objectnat-2.2.0.dist-info/sboms/objectnat-rs.cyclonedx.json +1105 -0
objectnat/__init__.py
ADDED
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,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)
|