voxcity 0.7.0__py3-none-any.whl → 1.0.2__py3-none-any.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.
- voxcity/__init__.py +14 -14
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +721 -675
- voxcity/generator/grids.py +381 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +282 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1488 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +5 -2
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +113 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1145 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/METADATA +90 -49
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/RECORD +42 -38
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
voxcity/geoprocessor/network.py
CHANGED
|
@@ -1,709 +1,709 @@
|
|
|
1
|
-
import contextily as ctx
|
|
2
|
-
import matplotlib.pyplot as plt
|
|
3
|
-
import numpy as np
|
|
4
|
-
import pandas as pd
|
|
5
|
-
import geopandas as gpd
|
|
6
|
-
from shapely.geometry import LineString, Polygon
|
|
7
|
-
import shapely.ops as ops
|
|
8
|
-
import networkx as nx
|
|
9
|
-
import osmnx as ox
|
|
10
|
-
import os
|
|
11
|
-
import shapely
|
|
12
|
-
from shapely.geometry import Point
|
|
13
|
-
from shapely.ops import transform
|
|
14
|
-
import pyproj
|
|
15
|
-
from pyproj import Transformer
|
|
16
|
-
from joblib import Parallel, delayed
|
|
17
|
-
|
|
18
|
-
from .raster import grid_to_geodataframe
|
|
19
|
-
|
|
20
|
-
def vectorized_edge_values(G, polygons_gdf, value_col='value'):
|
|
21
|
-
"""
|
|
22
|
-
Compute average polygon values along each edge in a network graph using vectorized operations.
|
|
23
|
-
|
|
24
|
-
This function performs efficient computation of average values from polygons that intersect
|
|
25
|
-
with network edges. It uses GeoDataFrames for vectorized spatial operations instead of
|
|
26
|
-
iterating over individual edges.
|
|
27
|
-
|
|
28
|
-
Parameters
|
|
29
|
-
----------
|
|
30
|
-
G : networkx.MultiDiGraph
|
|
31
|
-
OSMnx graph with edges containing either geometry attributes or node coordinates.
|
|
32
|
-
polygons_gdf : geopandas.GeoDataFrame
|
|
33
|
-
GeoDataFrame containing polygons with values to be averaged along edges.
|
|
34
|
-
value_col : str, default='value'
|
|
35
|
-
Name of the column in polygons_gdf containing the values to average.
|
|
36
|
-
|
|
37
|
-
Returns
|
|
38
|
-
-------
|
|
39
|
-
dict
|
|
40
|
-
Dictionary mapping edge tuples (u, v, k) to their computed average values.
|
|
41
|
-
Values are length-weighted averages of intersecting polygon values.
|
|
42
|
-
|
|
43
|
-
Notes
|
|
44
|
-
-----
|
|
45
|
-
The process involves:
|
|
46
|
-
1. Converting edges to a GeoDataFrame with LineString geometries
|
|
47
|
-
2. Projecting geometries to a metric CRS (EPSG:3857) for accurate length calculations
|
|
48
|
-
3. Computing intersections between edges and polygons
|
|
49
|
-
4. Calculating length-weighted averages of polygon values for each edge
|
|
50
|
-
"""
|
|
51
|
-
# Build edge GeoDataFrame in WGS84 (EPSG:4326)
|
|
52
|
-
records = []
|
|
53
|
-
for i, (u, v, k, data) in enumerate(G.edges(keys=True, data=True)):
|
|
54
|
-
if 'geometry' in data:
|
|
55
|
-
edge_geom = data['geometry']
|
|
56
|
-
else:
|
|
57
|
-
# Create LineString from node coordinates if no geometry exists
|
|
58
|
-
start_node = G.nodes[u]
|
|
59
|
-
end_node = G.nodes[v]
|
|
60
|
-
edge_geom = LineString([(start_node['x'], start_node['y']),
|
|
61
|
-
(end_node['x'], end_node['y'])])
|
|
62
|
-
records.append({
|
|
63
|
-
'edge_id': i, # unique ID for grouping
|
|
64
|
-
'u': u,
|
|
65
|
-
'v': v,
|
|
66
|
-
'k': k,
|
|
67
|
-
'geometry': edge_geom
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
edges_gdf = gpd.GeoDataFrame(records, crs="EPSG:4326")
|
|
71
|
-
if polygons_gdf.crs != edges_gdf.crs:
|
|
72
|
-
polygons_gdf = polygons_gdf.to_crs(edges_gdf.crs)
|
|
73
|
-
|
|
74
|
-
# Project to Web Mercator for accurate length calculations
|
|
75
|
-
edges_3857 = edges_gdf.to_crs(epsg=3857)
|
|
76
|
-
polys_3857 = polygons_gdf.to_crs(epsg=3857)
|
|
77
|
-
|
|
78
|
-
# Compute intersections between edges and polygons
|
|
79
|
-
intersected = gpd.overlay(edges_3857, polys_3857, how='intersection')
|
|
80
|
-
|
|
81
|
-
# Calculate length-weighted averages
|
|
82
|
-
intersected['seg_length'] = intersected.geometry.length
|
|
83
|
-
intersected['weighted_val'] = intersected['seg_length'] * intersected[value_col]
|
|
84
|
-
|
|
85
|
-
# Group by edge and compute weighted averages
|
|
86
|
-
grouped = intersected.groupby('edge_id')
|
|
87
|
-
results = grouped.apply(
|
|
88
|
-
lambda df: df['weighted_val'].sum() / df['seg_length'].sum()
|
|
89
|
-
if df['seg_length'].sum() > 0 else np.nan
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
# Map results back to edge tuples
|
|
93
|
-
edge_values = {}
|
|
94
|
-
for edge_id, val in results.items():
|
|
95
|
-
rec = edges_gdf.iloc[edge_id]
|
|
96
|
-
edge_values[(rec['u'], rec['v'], rec['k'])] = val
|
|
97
|
-
|
|
98
|
-
return edge_values
|
|
99
|
-
|
|
100
|
-
def get_network_values(
|
|
101
|
-
grid,
|
|
102
|
-
rectangle_vertices=None,
|
|
103
|
-
meshsize=None,
|
|
104
|
-
voxcity=None,
|
|
105
|
-
value_name='value',
|
|
106
|
-
**kwargs
|
|
107
|
-
):
|
|
108
|
-
"""
|
|
109
|
-
Extract and visualize values from a grid along a street network.
|
|
110
|
-
|
|
111
|
-
This function downloads a street network from OpenStreetMap for a given area,
|
|
112
|
-
computes average grid values along network edges, and optionally visualizes
|
|
113
|
-
the results on an interactive map.
|
|
114
|
-
|
|
115
|
-
Parameters
|
|
116
|
-
----------
|
|
117
|
-
grid : array-like or geopandas.GeoDataFrame
|
|
118
|
-
Either a grid array of values or a pre-built GeoDataFrame with polygons and values.
|
|
119
|
-
rectangle_vertices : list of tuples, optional
|
|
120
|
-
List of (lon, lat) coordinates defining the bounding rectangle in EPSG:4326.
|
|
121
|
-
Optional if `voxcity` is provided.
|
|
122
|
-
meshsize : float, optional
|
|
123
|
-
Size of each grid cell (used only if grid is array-like). Optional if `voxcity` is provided.
|
|
124
|
-
voxcity : VoxCity, optional
|
|
125
|
-
VoxCity object from which `rectangle_vertices` and `meshsize` will be derived if not supplied.
|
|
126
|
-
value_name : str, default='value'
|
|
127
|
-
Name to use for the edge attribute storing computed values.
|
|
128
|
-
**kwargs : dict
|
|
129
|
-
Additional visualization and processing parameters:
|
|
130
|
-
- network_type : str, default='walk'
|
|
131
|
-
Type of street network to download ('walk', 'drive', etc.)
|
|
132
|
-
- vis_graph : bool, default=True
|
|
133
|
-
Whether to display the visualization
|
|
134
|
-
- colormap : str, default='viridis'
|
|
135
|
-
Matplotlib colormap for edge colors
|
|
136
|
-
- vmin, vmax : float, optional
|
|
137
|
-
Value range for color mapping
|
|
138
|
-
- edge_width : float, default=1
|
|
139
|
-
Width of edge lines in visualization
|
|
140
|
-
- fig_size : tuple, default=(15,15)
|
|
141
|
-
Figure size in inches
|
|
142
|
-
- zoom : int, default=16
|
|
143
|
-
Zoom level for basemap
|
|
144
|
-
- basemap_style : ctx.providers, default=CartoDB.Positron
|
|
145
|
-
Contextily basemap provider
|
|
146
|
-
- save_path : str, optional
|
|
147
|
-
Path to save the edge GeoDataFrame as a GeoPackage
|
|
148
|
-
|
|
149
|
-
Returns
|
|
150
|
-
-------
|
|
151
|
-
tuple
|
|
152
|
-
(networkx.MultiDiGraph, geopandas.GeoDataFrame)
|
|
153
|
-
The network graph with computed edge values and edge geometries as a GeoDataFrame.
|
|
154
|
-
"""
|
|
155
|
-
defaults = {
|
|
156
|
-
'network_type': 'walk',
|
|
157
|
-
'vis_graph': True,
|
|
158
|
-
'colormap': 'viridis',
|
|
159
|
-
'vmin': None,
|
|
160
|
-
'vmax': None,
|
|
161
|
-
'edge_width': 1,
|
|
162
|
-
'fig_size': (15,15),
|
|
163
|
-
'zoom': 16,
|
|
164
|
-
'basemap_style': ctx.providers.CartoDB.Positron,
|
|
165
|
-
'save_path': None
|
|
166
|
-
}
|
|
167
|
-
settings = {**defaults, **kwargs}
|
|
168
|
-
|
|
169
|
-
# Derive geometry parameters from VoxCity if supplied (inline to avoid extra helper)
|
|
170
|
-
if voxcity is not None:
|
|
171
|
-
derived_rv = None
|
|
172
|
-
derived_meshsize = None
|
|
173
|
-
# Try extras['rectangle_vertices'] when available
|
|
174
|
-
if hasattr(voxcity, "extras") and isinstance(voxcity.extras, dict):
|
|
175
|
-
derived_rv = voxcity.extras.get("rectangle_vertices")
|
|
176
|
-
# Pull meshsize and bounds from voxels.meta
|
|
177
|
-
voxels = getattr(voxcity, "voxels", None)
|
|
178
|
-
meta = getattr(voxels, "meta", None) if voxels is not None else None
|
|
179
|
-
if meta is not None:
|
|
180
|
-
derived_meshsize = getattr(meta, "meshsize", None)
|
|
181
|
-
if derived_rv is None:
|
|
182
|
-
bounds = getattr(meta, "bounds", None)
|
|
183
|
-
if bounds is not None:
|
|
184
|
-
west, south, east, north = bounds
|
|
185
|
-
derived_rv = [(west, south), (west, north), (east, north), (east, south)]
|
|
186
|
-
if rectangle_vertices is None:
|
|
187
|
-
rectangle_vertices = derived_rv
|
|
188
|
-
if meshsize is None:
|
|
189
|
-
meshsize = derived_meshsize
|
|
190
|
-
|
|
191
|
-
if rectangle_vertices is None:
|
|
192
|
-
raise ValueError("rectangle_vertices must be provided, either directly or via `voxcity`.")
|
|
193
|
-
|
|
194
|
-
# Build polygons GDF if needed
|
|
195
|
-
polygons_gdf = (grid if isinstance(grid, gpd.GeoDataFrame)
|
|
196
|
-
else grid_to_geodataframe(grid, rectangle_vertices, meshsize))
|
|
197
|
-
if polygons_gdf.crs is None:
|
|
198
|
-
polygons_gdf.set_crs(epsg=4326, inplace=True)
|
|
199
|
-
|
|
200
|
-
# BBox
|
|
201
|
-
north, south = rectangle_vertices[1][1], rectangle_vertices[0][1]
|
|
202
|
-
east, west = rectangle_vertices[2][0], rectangle_vertices[0][0]
|
|
203
|
-
bbox = (west, south, east, north)
|
|
204
|
-
|
|
205
|
-
# Download OSMnx network
|
|
206
|
-
G = ox.graph.graph_from_bbox(
|
|
207
|
-
bbox=bbox,
|
|
208
|
-
network_type=settings['network_type'],
|
|
209
|
-
simplify=True
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
# Compute edge values with the vectorized function
|
|
213
|
-
edge_values = vectorized_edge_values(G, polygons_gdf, value_col="value")
|
|
214
|
-
nx.set_edge_attributes(G, edge_values, name=value_name)
|
|
215
|
-
|
|
216
|
-
# Build edge GDF
|
|
217
|
-
edges_with_values = []
|
|
218
|
-
for u, v, k, data in G.edges(data=True, keys=True):
|
|
219
|
-
if 'geometry' in data:
|
|
220
|
-
geom = data['geometry']
|
|
221
|
-
else:
|
|
222
|
-
start_node = G.nodes[u]
|
|
223
|
-
end_node = G.nodes[v]
|
|
224
|
-
geom = LineString([(start_node['x'], start_node['y']),
|
|
225
|
-
(end_node['x'], end_node['y'])])
|
|
226
|
-
|
|
227
|
-
val = data.get(value_name, np.nan)
|
|
228
|
-
edges_with_values.append({
|
|
229
|
-
'u': u, 'v': v, 'key': k,
|
|
230
|
-
'geometry': geom,
|
|
231
|
-
value_name: val
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
|
|
235
|
-
|
|
236
|
-
# Save
|
|
237
|
-
if settings['save_path']:
|
|
238
|
-
edge_gdf.to_file(settings['save_path'], driver="GPKG")
|
|
239
|
-
|
|
240
|
-
if settings['vis_graph']:
|
|
241
|
-
edge_gdf_web = edge_gdf.to_crs(epsg=3857)
|
|
242
|
-
fig, ax = plt.subplots(figsize=settings['fig_size'])
|
|
243
|
-
edge_gdf_web.plot(
|
|
244
|
-
column=value_name,
|
|
245
|
-
ax=ax,
|
|
246
|
-
cmap=settings['colormap'],
|
|
247
|
-
legend=True,
|
|
248
|
-
vmin=settings['vmin'],
|
|
249
|
-
vmax=settings['vmax'],
|
|
250
|
-
linewidth=settings['edge_width'],
|
|
251
|
-
legend_kwds={'label': value_name, 'shrink': 0.5}
|
|
252
|
-
)
|
|
253
|
-
ctx.add_basemap(ax, source=settings['basemap_style'], zoom=settings['zoom'])
|
|
254
|
-
ax.set_axis_off()
|
|
255
|
-
plt.show()
|
|
256
|
-
|
|
257
|
-
return G, edge_gdf
|
|
258
|
-
|
|
259
|
-
# -------------------------------------------------------------------
|
|
260
|
-
# 1) Functions for interpolation, parallelization, and slope
|
|
261
|
-
# -------------------------------------------------------------------
|
|
262
|
-
|
|
263
|
-
def interpolate_points_along_line(line, interval):
|
|
264
|
-
"""
|
|
265
|
-
Interpolate points along a single LineString at a given interval (in meters).
|
|
266
|
-
If the line is shorter than `interval`, only start/end points are returned.
|
|
267
|
-
|
|
268
|
-
This function handles coordinate system transformations to ensure accurate
|
|
269
|
-
distance measurements, working in Web Mercator (EPSG:3857) for distance
|
|
270
|
-
calculations while maintaining WGS84 (EPSG:4326) for input/output.
|
|
271
|
-
|
|
272
|
-
Parameters
|
|
273
|
-
----------
|
|
274
|
-
line : shapely.geometry.LineString
|
|
275
|
-
Edge geometry in EPSG:4326 (lon/lat).
|
|
276
|
-
interval : float
|
|
277
|
-
Distance in meters between interpolated points.
|
|
278
|
-
|
|
279
|
-
Returns
|
|
280
|
-
-------
|
|
281
|
-
list of shapely.geometry.Point
|
|
282
|
-
Points in EPSG:4326 along the line, spaced approximately `interval` meters apart.
|
|
283
|
-
For lines shorter than interval, only start and end points are returned.
|
|
284
|
-
For empty lines, an empty list is returned.
|
|
285
|
-
"""
|
|
286
|
-
if line.is_empty:
|
|
287
|
-
return []
|
|
288
|
-
|
|
289
|
-
# Transformers for metric distance calculations
|
|
290
|
-
project = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
|
|
291
|
-
project_rev = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True).transform
|
|
292
|
-
|
|
293
|
-
# Project line to Web Mercator
|
|
294
|
-
line_merc = shapely.ops.transform(project, line)
|
|
295
|
-
length_m = line_merc.length
|
|
296
|
-
if length_m == 0:
|
|
297
|
-
return [Point(line.coords[0])]
|
|
298
|
-
|
|
299
|
-
# If line is shorter than interval, just start & end
|
|
300
|
-
if length_m < interval:
|
|
301
|
-
return [Point(line.coords[0]), Point(line.coords[-1])]
|
|
302
|
-
|
|
303
|
-
# Otherwise, create distances
|
|
304
|
-
num_points = int(length_m // interval)
|
|
305
|
-
dists = [i * interval for i in range(num_points + 1)]
|
|
306
|
-
# Ensure end
|
|
307
|
-
if dists[-1] < length_m:
|
|
308
|
-
dists.append(length_m)
|
|
309
|
-
|
|
310
|
-
# Interpolate
|
|
311
|
-
points_merc = [line_merc.interpolate(d) for d in dists]
|
|
312
|
-
# Reproject back
|
|
313
|
-
return [shapely.ops.transform(project_rev, pt) for pt in points_merc]
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def gather_interpolation_points(G, interval=10.0, n_jobs=1):
|
|
317
|
-
"""
|
|
318
|
-
Gather all interpolation points for each edge in the graph into a single GeoDataFrame.
|
|
319
|
-
Supports parallel processing for improved performance on large networks.
|
|
320
|
-
|
|
321
|
-
This function processes each edge in the graph, either using its geometry attribute
|
|
322
|
-
or creating a LineString from node coordinates, then interpolates points along it
|
|
323
|
-
at the specified interval.
|
|
324
|
-
|
|
325
|
-
Parameters
|
|
326
|
-
----------
|
|
327
|
-
G : networkx.MultiDiGraph
|
|
328
|
-
OSMnx graph with 'geometry' attributes or x,y coordinates in the nodes.
|
|
329
|
-
interval : float, default=10.0
|
|
330
|
-
Interpolation distance interval in meters.
|
|
331
|
-
n_jobs : int, default=1
|
|
332
|
-
Number of parallel jobs for processing edges. Set to 1 for sequential processing,
|
|
333
|
-
or -1 to use all available CPU cores.
|
|
334
|
-
|
|
335
|
-
Returns
|
|
336
|
-
-------
|
|
337
|
-
gpd.GeoDataFrame
|
|
338
|
-
GeoDataFrame in EPSG:4326 with columns:
|
|
339
|
-
- edge_id: Index of the edge in the graph
|
|
340
|
-
- index_in_edge: Position of the point along its edge
|
|
341
|
-
- geometry: Point geometry
|
|
342
|
-
"""
|
|
343
|
-
edges = list(G.edges(keys=True, data=True))
|
|
344
|
-
|
|
345
|
-
def process_edge(u, v, k, data, idx):
|
|
346
|
-
if 'geometry' in data:
|
|
347
|
-
line = data['geometry']
|
|
348
|
-
else:
|
|
349
|
-
# If no geometry, build from node coords
|
|
350
|
-
start_node = G.nodes[u]
|
|
351
|
-
end_node = G.nodes[v]
|
|
352
|
-
line = LineString([(start_node['x'], start_node['y']),
|
|
353
|
-
(end_node['x'], end_node['y'])])
|
|
354
|
-
|
|
355
|
-
pts = interpolate_points_along_line(line, interval)
|
|
356
|
-
df = pd.DataFrame({
|
|
357
|
-
'edge_id': [idx]*len(pts),
|
|
358
|
-
'index_in_edge': np.arange(len(pts)),
|
|
359
|
-
'geometry': pts
|
|
360
|
-
})
|
|
361
|
-
return df
|
|
362
|
-
|
|
363
|
-
# Parallel interpolation
|
|
364
|
-
results = Parallel(n_jobs=n_jobs, backend='threading')(
|
|
365
|
-
delayed(process_edge)(u, v, k, data, i)
|
|
366
|
-
for i, (u, v, k, data) in enumerate(edges)
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
all_points_df = pd.concat(results, ignore_index=True)
|
|
370
|
-
points_gdf = gpd.GeoDataFrame(all_points_df, geometry='geometry', crs="EPSG:4326")
|
|
371
|
-
return points_gdf
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value'):
|
|
375
|
-
"""
|
|
376
|
-
Perform a spatial join to fetch DEM elevations for interpolated points.
|
|
377
|
-
|
|
378
|
-
Uses nearest neighbor matching in projected coordinates (EPSG:3857) to ensure
|
|
379
|
-
accurate distance calculations when finding the closest DEM cell for each point.
|
|
380
|
-
|
|
381
|
-
Parameters
|
|
382
|
-
----------
|
|
383
|
-
points_gdf_3857 : gpd.GeoDataFrame
|
|
384
|
-
Interpolation points in EPSG:3857 projection.
|
|
385
|
-
dem_gdf_3857 : gpd.GeoDataFrame
|
|
386
|
-
DEM polygons in EPSG:3857 projection, containing elevation values.
|
|
387
|
-
elevation_col : str, default='value'
|
|
388
|
-
Name of the column containing elevation values in dem_gdf_3857.
|
|
389
|
-
|
|
390
|
-
Returns
|
|
391
|
-
-------
|
|
392
|
-
gpd.GeoDataFrame
|
|
393
|
-
Copy of points_gdf_3857 with additional columns:
|
|
394
|
-
- elevation: Elevation value from nearest DEM cell
|
|
395
|
-
- dist_to_poly: Distance to nearest DEM cell
|
|
396
|
-
"""
|
|
397
|
-
joined = gpd.sjoin_nearest(
|
|
398
|
-
points_gdf_3857,
|
|
399
|
-
dem_gdf_3857[[elevation_col, 'geometry']].copy(),
|
|
400
|
-
how='left',
|
|
401
|
-
distance_col='dist_to_poly'
|
|
402
|
-
)
|
|
403
|
-
joined.rename(columns={elevation_col: 'elevation'}, inplace=True)
|
|
404
|
-
return joined
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def compute_slope_for_group(df):
|
|
408
|
-
"""
|
|
409
|
-
Compute average slope between consecutive points along a single edge.
|
|
410
|
-
|
|
411
|
-
Slopes are calculated as absolute percentage grade (rise/run * 100) between
|
|
412
|
-
consecutive points, then averaged for the entire edge. Points must be in
|
|
413
|
-
EPSG:3857 projection for accurate horizontal distance calculations.
|
|
414
|
-
|
|
415
|
-
Parameters
|
|
416
|
-
----------
|
|
417
|
-
df : pd.DataFrame
|
|
418
|
-
DataFrame containing points for a single edge with columns:
|
|
419
|
-
- geometry: Point geometries in EPSG:3857
|
|
420
|
-
- elevation: Elevation values in meters
|
|
421
|
-
- index_in_edge: Position along the edge for sorting
|
|
422
|
-
|
|
423
|
-
Returns
|
|
424
|
-
-------
|
|
425
|
-
float
|
|
426
|
-
Average slope as a percentage, or np.nan if no valid slopes can be computed
|
|
427
|
-
(e.g., when points are coincident or no elevation change).
|
|
428
|
-
"""
|
|
429
|
-
# Sort by position along the edge
|
|
430
|
-
df = df.sort_values("index_in_edge")
|
|
431
|
-
|
|
432
|
-
# Coordinates
|
|
433
|
-
xs = df.geometry.x.to_numpy()
|
|
434
|
-
ys = df.geometry.y.to_numpy()
|
|
435
|
-
elevs = df["elevation"].to_numpy()
|
|
436
|
-
|
|
437
|
-
# Differences
|
|
438
|
-
dx = np.diff(xs)
|
|
439
|
-
dy = np.diff(ys)
|
|
440
|
-
horizontal_dist = np.sqrt(dx**2 + dy**2)
|
|
441
|
-
elev_diff = np.diff(elevs)
|
|
442
|
-
|
|
443
|
-
# Slope in %
|
|
444
|
-
valid_mask = horizontal_dist > 0
|
|
445
|
-
slopes = (np.abs(elev_diff[valid_mask]) / horizontal_dist[valid_mask]) * 100
|
|
446
|
-
|
|
447
|
-
if len(slopes) == 0:
|
|
448
|
-
return np.nan
|
|
449
|
-
return slopes.mean()
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def calculate_edge_slopes_from_join(joined_points_gdf, n_edges):
|
|
453
|
-
"""
|
|
454
|
-
Calculate average slopes for all edges in the network from interpolated points.
|
|
455
|
-
|
|
456
|
-
This function groups points by edge_id and computes the average slope for each edge
|
|
457
|
-
using the compute_slope_for_group function. It ensures all edges in the original
|
|
458
|
-
graph have a slope value, even if no valid slope could be computed.
|
|
459
|
-
|
|
460
|
-
Parameters
|
|
461
|
-
----------
|
|
462
|
-
joined_points_gdf : gpd.GeoDataFrame
|
|
463
|
-
Points with elevations in EPSG:3857, must have columns:
|
|
464
|
-
- edge_id: Index of the edge in the graph
|
|
465
|
-
- index_in_edge: Position along the edge
|
|
466
|
-
- elevation: Elevation value
|
|
467
|
-
- geometry: Point geometry
|
|
468
|
-
n_edges : int
|
|
469
|
-
Total number of edges in the original graph.
|
|
470
|
-
|
|
471
|
-
Returns
|
|
472
|
-
-------
|
|
473
|
-
dict
|
|
474
|
-
Dictionary mapping edge_id to average slope (in %). Edges with no valid
|
|
475
|
-
slope calculation are assigned np.nan.
|
|
476
|
-
"""
|
|
477
|
-
# We'll group by edge_id, ignoring the group columns in apply (pandas >= 2.1).
|
|
478
|
-
# If your pandas version < 2.1, just do a column subset after groupby.
|
|
479
|
-
# E.g. .groupby("edge_id", group_keys=False)[["geometry","elevation","index_in_edge"]]...
|
|
480
|
-
grouped = joined_points_gdf.groupby("edge_id", group_keys=False)
|
|
481
|
-
results = grouped[["geometry", "elevation", "index_in_edge"]].apply(compute_slope_for_group)
|
|
482
|
-
|
|
483
|
-
# Convert series -> dict
|
|
484
|
-
slope_dict = results.to_dict()
|
|
485
|
-
|
|
486
|
-
# Fill any missing edge IDs with NaN
|
|
487
|
-
for i in range(n_edges):
|
|
488
|
-
if i not in slope_dict:
|
|
489
|
-
slope_dict[i] = np.nan
|
|
490
|
-
|
|
491
|
-
return slope_dict
|
|
492
|
-
|
|
493
|
-
# -------------------------------------------------------------------
|
|
494
|
-
# 2) Main function to analyze network slopes
|
|
495
|
-
# -------------------------------------------------------------------
|
|
496
|
-
|
|
497
|
-
def analyze_network_slopes(
|
|
498
|
-
dem_grid,
|
|
499
|
-
meshsize,
|
|
500
|
-
value_name='slope',
|
|
501
|
-
interval=10.0,
|
|
502
|
-
n_jobs=1,
|
|
503
|
-
**kwargs
|
|
504
|
-
):
|
|
505
|
-
"""
|
|
506
|
-
Analyze and visualize street network slopes using Digital Elevation Model (DEM) data.
|
|
507
|
-
|
|
508
|
-
This function performs a comprehensive analysis of street network slopes by:
|
|
509
|
-
1. Converting DEM data to a GeoDataFrame of elevation polygons
|
|
510
|
-
2. Downloading the street network from OpenStreetMap
|
|
511
|
-
3. Interpolating points along network edges
|
|
512
|
-
4. Matching points to DEM elevations
|
|
513
|
-
5. Computing slopes between consecutive points
|
|
514
|
-
6. Aggregating slopes per edge
|
|
515
|
-
7. Optionally visualizing results on an interactive map
|
|
516
|
-
|
|
517
|
-
The analysis uses appropriate coordinate transformations between WGS84 (EPSG:4326)
|
|
518
|
-
for geographic operations and Web Mercator (EPSG:3857) for distance calculations.
|
|
519
|
-
|
|
520
|
-
Parameters
|
|
521
|
-
----------
|
|
522
|
-
dem_grid : array-like
|
|
523
|
-
Digital Elevation Model grid data containing elevation values.
|
|
524
|
-
meshsize : float
|
|
525
|
-
Size of each DEM grid cell.
|
|
526
|
-
value_name : str, default='slope'
|
|
527
|
-
Name to use for the slope attribute in output data.
|
|
528
|
-
interval : float, default=10.0
|
|
529
|
-
Distance in meters between interpolated points along edges.
|
|
530
|
-
n_jobs : int, default=1
|
|
531
|
-
Number of parallel jobs for edge processing.
|
|
532
|
-
**kwargs : dict
|
|
533
|
-
Additional configuration parameters:
|
|
534
|
-
- rectangle_vertices : list of (lon, lat), required
|
|
535
|
-
Coordinates defining the analysis area in EPSG:4326
|
|
536
|
-
- network_type : str, default='walk'
|
|
537
|
-
Type of street network to download
|
|
538
|
-
- vis_graph : bool, default=True
|
|
539
|
-
Whether to create visualization
|
|
540
|
-
- colormap : str, default='viridis'
|
|
541
|
-
Matplotlib colormap for slope visualization
|
|
542
|
-
- vmin, vmax : float, optional
|
|
543
|
-
Value range for slope coloring
|
|
544
|
-
- edge_width : float, default=1
|
|
545
|
-
Width of edge lines in plot
|
|
546
|
-
- fig_size : tuple, default=(15,15)
|
|
547
|
-
Figure size in inches
|
|
548
|
-
- zoom : int, default=16
|
|
549
|
-
Zoom level for basemap
|
|
550
|
-
- basemap_style : ctx.providers, default=CartoDB.Positron
|
|
551
|
-
Contextily basemap provider
|
|
552
|
-
- output_directory : str, optional
|
|
553
|
-
Directory to save results
|
|
554
|
-
- output_file_name : str, default='network_slopes'
|
|
555
|
-
Base name for output files
|
|
556
|
-
- alpha : float, default=1.0
|
|
557
|
-
Transparency of edge lines in visualization
|
|
558
|
-
|
|
559
|
-
Returns
|
|
560
|
-
-------
|
|
561
|
-
tuple
|
|
562
|
-
(networkx.MultiDiGraph, geopandas.GeoDataFrame)
|
|
563
|
-
- Graph with slope values as edge attributes
|
|
564
|
-
- GeoDataFrame of edges with geometries and slope values
|
|
565
|
-
|
|
566
|
-
Notes
|
|
567
|
-
-----
|
|
568
|
-
- Slopes are calculated as absolute percentage grades (rise/run * 100)
|
|
569
|
-
- Edge slopes are length-weighted averages of point-to-point slopes
|
|
570
|
-
- The visualization includes a basemap and legend showing slope percentages
|
|
571
|
-
- If output_directory is specified, results are saved as a GeoPackage
|
|
572
|
-
"""
|
|
573
|
-
defaults = {
|
|
574
|
-
'rectangle_vertices': None,
|
|
575
|
-
'network_type': 'walk',
|
|
576
|
-
'vis_graph': True,
|
|
577
|
-
'colormap': 'viridis',
|
|
578
|
-
'vmin': None,
|
|
579
|
-
'vmax': None,
|
|
580
|
-
'edge_width': 1,
|
|
581
|
-
'fig_size': (15, 15),
|
|
582
|
-
'zoom': 16,
|
|
583
|
-
'basemap_style': ctx.providers.CartoDB.Positron,
|
|
584
|
-
'output_directory': None,
|
|
585
|
-
'output_file_name': 'network_slopes',
|
|
586
|
-
'alpha': 1.0
|
|
587
|
-
}
|
|
588
|
-
settings = {**defaults, **kwargs}
|
|
589
|
-
|
|
590
|
-
# Validate bounding box
|
|
591
|
-
if settings['rectangle_vertices'] is None:
|
|
592
|
-
raise ValueError("Must supply 'rectangle_vertices' in kwargs.")
|
|
593
|
-
|
|
594
|
-
# 1) Build DEM GeoDataFrame in EPSG:4326
|
|
595
|
-
dem_gdf = grid_to_geodataframe(dem_grid, settings['rectangle_vertices'], meshsize)
|
|
596
|
-
if dem_gdf.crs is None:
|
|
597
|
-
dem_gdf.set_crs(epsg=4326, inplace=True)
|
|
598
|
-
|
|
599
|
-
# 2) Download bounding box from rectangle_vertices
|
|
600
|
-
north, south = settings['rectangle_vertices'][1][1], settings['rectangle_vertices'][0][1]
|
|
601
|
-
east, west = settings['rectangle_vertices'][2][0], settings['rectangle_vertices'][0][0]
|
|
602
|
-
bbox = (west, south, east, north)
|
|
603
|
-
|
|
604
|
-
G = ox.graph.graph_from_bbox(
|
|
605
|
-
bbox=bbox,
|
|
606
|
-
network_type=settings['network_type'],
|
|
607
|
-
simplify=True
|
|
608
|
-
)
|
|
609
|
-
|
|
610
|
-
# 3) Interpolate points along edges (EPSG:4326)
|
|
611
|
-
points_gdf_4326 = gather_interpolation_points(G, interval=interval, n_jobs=n_jobs)
|
|
612
|
-
|
|
613
|
-
# 4) Reproject DEM + Points to EPSG:3857 for correct distance operations
|
|
614
|
-
dem_gdf_3857 = dem_gdf.to_crs(epsg=3857)
|
|
615
|
-
points_gdf_3857 = points_gdf_4326.to_crs(epsg=3857)
|
|
616
|
-
|
|
617
|
-
# 5) Perform spatial join to get elevations
|
|
618
|
-
joined_points_3857 = fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value')
|
|
619
|
-
|
|
620
|
-
# 6) Compute slopes for each edge
|
|
621
|
-
n_edges = len(list(G.edges(keys=True)))
|
|
622
|
-
slope_dict = calculate_edge_slopes_from_join(joined_points_3857, n_edges)
|
|
623
|
-
|
|
624
|
-
# 7) Assign slopes back to G
|
|
625
|
-
edges = list(G.edges(keys=True, data=True))
|
|
626
|
-
edge_slopes = {}
|
|
627
|
-
for i, (u, v, k, data) in enumerate(edges):
|
|
628
|
-
edge_slopes[(u, v, k)] = slope_dict.get(i, np.nan)
|
|
629
|
-
nx.set_edge_attributes(G, edge_slopes, name=value_name)
|
|
630
|
-
|
|
631
|
-
# 8) Build an edge GeoDataFrame in EPSG:4326
|
|
632
|
-
edges_with_values = []
|
|
633
|
-
for (u, v, k, data), edge_id in zip(edges, range(len(edges))):
|
|
634
|
-
if 'geometry' in data:
|
|
635
|
-
geom = data['geometry']
|
|
636
|
-
else:
|
|
637
|
-
start_node = G.nodes[u]
|
|
638
|
-
end_node = G.nodes[v]
|
|
639
|
-
geom = LineString([(start_node['x'], start_node['y']),
|
|
640
|
-
(end_node['x'], end_node['y'])])
|
|
641
|
-
|
|
642
|
-
edges_with_values.append({
|
|
643
|
-
'u': u,
|
|
644
|
-
'v': v,
|
|
645
|
-
'key': k,
|
|
646
|
-
'geometry': geom,
|
|
647
|
-
value_name: slope_dict.get(edge_id, np.nan)
|
|
648
|
-
})
|
|
649
|
-
|
|
650
|
-
edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
|
|
651
|
-
|
|
652
|
-
# 9) Save output if requested
|
|
653
|
-
if settings['output_directory']:
|
|
654
|
-
os.makedirs(settings['output_directory'], exist_ok=True)
|
|
655
|
-
out_path = os.path.join(
|
|
656
|
-
settings['output_directory'],
|
|
657
|
-
f"{settings['output_file_name']}.gpkg"
|
|
658
|
-
)
|
|
659
|
-
edge_gdf.to_file(out_path, driver="GPKG")
|
|
660
|
-
|
|
661
|
-
# 10) Visualization
|
|
662
|
-
if settings['vis_graph']:
|
|
663
|
-
# Create a Polygon from the rectangle vertices
|
|
664
|
-
rectangle_polygon = Polygon(settings['rectangle_vertices'])
|
|
665
|
-
|
|
666
|
-
# Convert the rectangle polygon to the same CRS as edge_gdf_web
|
|
667
|
-
rectangle_gdf = gpd.GeoDataFrame(index=[0], crs='epsg:4326', geometry=[rectangle_polygon])
|
|
668
|
-
rectangle_gdf_web = rectangle_gdf.to_crs(epsg=3857)
|
|
669
|
-
|
|
670
|
-
# Get the bounding box of the rectangle
|
|
671
|
-
minx, miny, maxx, maxy = rectangle_gdf_web.total_bounds
|
|
672
|
-
|
|
673
|
-
# Plot the edges
|
|
674
|
-
edge_gdf_web = edge_gdf.to_crs(epsg=3857)
|
|
675
|
-
fig, ax = plt.subplots(figsize=settings['fig_size'])
|
|
676
|
-
edge_gdf_web.plot(
|
|
677
|
-
column=value_name,
|
|
678
|
-
ax=ax,
|
|
679
|
-
cmap=settings['colormap'],
|
|
680
|
-
legend=True,
|
|
681
|
-
vmin=settings['vmin'],
|
|
682
|
-
vmax=settings['vmax'],
|
|
683
|
-
linewidth=settings['edge_width'],
|
|
684
|
-
alpha=settings['alpha'],
|
|
685
|
-
legend_kwds={'label': f"{value_name} (%)"}
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
# Add basemap with the same extent as the rectangle
|
|
689
|
-
ctx.add_basemap(
|
|
690
|
-
ax,
|
|
691
|
-
source=settings['basemap_style'],
|
|
692
|
-
zoom=settings['zoom'],
|
|
693
|
-
bounds=(minx, miny, maxx, maxy) # Explicitly set the bounds of the basemap
|
|
694
|
-
)
|
|
695
|
-
|
|
696
|
-
# Set the plot limits to the bounding box of the rectangle
|
|
697
|
-
ax.set_xlim(minx, maxx)
|
|
698
|
-
ax.set_ylim(miny, maxy)
|
|
699
|
-
|
|
700
|
-
# Turn off the axis
|
|
701
|
-
ax.set_axis_off()
|
|
702
|
-
|
|
703
|
-
# Add title
|
|
704
|
-
plt.title(f'Network {value_name} Analysis', pad=20)
|
|
705
|
-
|
|
706
|
-
# Show the plot
|
|
707
|
-
plt.show()
|
|
708
|
-
|
|
1
|
+
import contextily as ctx
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
from shapely.geometry import LineString, Polygon
|
|
7
|
+
import shapely.ops as ops
|
|
8
|
+
import networkx as nx
|
|
9
|
+
import osmnx as ox
|
|
10
|
+
import os
|
|
11
|
+
import shapely
|
|
12
|
+
from shapely.geometry import Point
|
|
13
|
+
from shapely.ops import transform
|
|
14
|
+
import pyproj
|
|
15
|
+
from pyproj import Transformer
|
|
16
|
+
from joblib import Parallel, delayed
|
|
17
|
+
|
|
18
|
+
from .raster import grid_to_geodataframe
|
|
19
|
+
|
|
20
|
+
def vectorized_edge_values(G, polygons_gdf, value_col='value'):
|
|
21
|
+
"""
|
|
22
|
+
Compute average polygon values along each edge in a network graph using vectorized operations.
|
|
23
|
+
|
|
24
|
+
This function performs efficient computation of average values from polygons that intersect
|
|
25
|
+
with network edges. It uses GeoDataFrames for vectorized spatial operations instead of
|
|
26
|
+
iterating over individual edges.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
G : networkx.MultiDiGraph
|
|
31
|
+
OSMnx graph with edges containing either geometry attributes or node coordinates.
|
|
32
|
+
polygons_gdf : geopandas.GeoDataFrame
|
|
33
|
+
GeoDataFrame containing polygons with values to be averaged along edges.
|
|
34
|
+
value_col : str, default='value'
|
|
35
|
+
Name of the column in polygons_gdf containing the values to average.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
dict
|
|
40
|
+
Dictionary mapping edge tuples (u, v, k) to their computed average values.
|
|
41
|
+
Values are length-weighted averages of intersecting polygon values.
|
|
42
|
+
|
|
43
|
+
Notes
|
|
44
|
+
-----
|
|
45
|
+
The process involves:
|
|
46
|
+
1. Converting edges to a GeoDataFrame with LineString geometries
|
|
47
|
+
2. Projecting geometries to a metric CRS (EPSG:3857) for accurate length calculations
|
|
48
|
+
3. Computing intersections between edges and polygons
|
|
49
|
+
4. Calculating length-weighted averages of polygon values for each edge
|
|
50
|
+
"""
|
|
51
|
+
# Build edge GeoDataFrame in WGS84 (EPSG:4326)
|
|
52
|
+
records = []
|
|
53
|
+
for i, (u, v, k, data) in enumerate(G.edges(keys=True, data=True)):
|
|
54
|
+
if 'geometry' in data:
|
|
55
|
+
edge_geom = data['geometry']
|
|
56
|
+
else:
|
|
57
|
+
# Create LineString from node coordinates if no geometry exists
|
|
58
|
+
start_node = G.nodes[u]
|
|
59
|
+
end_node = G.nodes[v]
|
|
60
|
+
edge_geom = LineString([(start_node['x'], start_node['y']),
|
|
61
|
+
(end_node['x'], end_node['y'])])
|
|
62
|
+
records.append({
|
|
63
|
+
'edge_id': i, # unique ID for grouping
|
|
64
|
+
'u': u,
|
|
65
|
+
'v': v,
|
|
66
|
+
'k': k,
|
|
67
|
+
'geometry': edge_geom
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
edges_gdf = gpd.GeoDataFrame(records, crs="EPSG:4326")
|
|
71
|
+
if polygons_gdf.crs != edges_gdf.crs:
|
|
72
|
+
polygons_gdf = polygons_gdf.to_crs(edges_gdf.crs)
|
|
73
|
+
|
|
74
|
+
# Project to Web Mercator for accurate length calculations
|
|
75
|
+
edges_3857 = edges_gdf.to_crs(epsg=3857)
|
|
76
|
+
polys_3857 = polygons_gdf.to_crs(epsg=3857)
|
|
77
|
+
|
|
78
|
+
# Compute intersections between edges and polygons
|
|
79
|
+
intersected = gpd.overlay(edges_3857, polys_3857, how='intersection')
|
|
80
|
+
|
|
81
|
+
# Calculate length-weighted averages
|
|
82
|
+
intersected['seg_length'] = intersected.geometry.length
|
|
83
|
+
intersected['weighted_val'] = intersected['seg_length'] * intersected[value_col]
|
|
84
|
+
|
|
85
|
+
# Group by edge and compute weighted averages
|
|
86
|
+
grouped = intersected.groupby('edge_id')
|
|
87
|
+
results = grouped.apply(
|
|
88
|
+
lambda df: df['weighted_val'].sum() / df['seg_length'].sum()
|
|
89
|
+
if df['seg_length'].sum() > 0 else np.nan
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Map results back to edge tuples
|
|
93
|
+
edge_values = {}
|
|
94
|
+
for edge_id, val in results.items():
|
|
95
|
+
rec = edges_gdf.iloc[edge_id]
|
|
96
|
+
edge_values[(rec['u'], rec['v'], rec['k'])] = val
|
|
97
|
+
|
|
98
|
+
return edge_values
|
|
99
|
+
|
|
100
|
+
def get_network_values(
|
|
101
|
+
grid,
|
|
102
|
+
rectangle_vertices=None,
|
|
103
|
+
meshsize=None,
|
|
104
|
+
voxcity=None,
|
|
105
|
+
value_name='value',
|
|
106
|
+
**kwargs
|
|
107
|
+
):
|
|
108
|
+
"""
|
|
109
|
+
Extract and visualize values from a grid along a street network.
|
|
110
|
+
|
|
111
|
+
This function downloads a street network from OpenStreetMap for a given area,
|
|
112
|
+
computes average grid values along network edges, and optionally visualizes
|
|
113
|
+
the results on an interactive map.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
grid : array-like or geopandas.GeoDataFrame
|
|
118
|
+
Either a grid array of values or a pre-built GeoDataFrame with polygons and values.
|
|
119
|
+
rectangle_vertices : list of tuples, optional
|
|
120
|
+
List of (lon, lat) coordinates defining the bounding rectangle in EPSG:4326.
|
|
121
|
+
Optional if `voxcity` is provided.
|
|
122
|
+
meshsize : float, optional
|
|
123
|
+
Size of each grid cell (used only if grid is array-like). Optional if `voxcity` is provided.
|
|
124
|
+
voxcity : VoxCity, optional
|
|
125
|
+
VoxCity object from which `rectangle_vertices` and `meshsize` will be derived if not supplied.
|
|
126
|
+
value_name : str, default='value'
|
|
127
|
+
Name to use for the edge attribute storing computed values.
|
|
128
|
+
**kwargs : dict
|
|
129
|
+
Additional visualization and processing parameters:
|
|
130
|
+
- network_type : str, default='walk'
|
|
131
|
+
Type of street network to download ('walk', 'drive', etc.)
|
|
132
|
+
- vis_graph : bool, default=True
|
|
133
|
+
Whether to display the visualization
|
|
134
|
+
- colormap : str, default='viridis'
|
|
135
|
+
Matplotlib colormap for edge colors
|
|
136
|
+
- vmin, vmax : float, optional
|
|
137
|
+
Value range for color mapping
|
|
138
|
+
- edge_width : float, default=1
|
|
139
|
+
Width of edge lines in visualization
|
|
140
|
+
- fig_size : tuple, default=(15,15)
|
|
141
|
+
Figure size in inches
|
|
142
|
+
- zoom : int, default=16
|
|
143
|
+
Zoom level for basemap
|
|
144
|
+
- basemap_style : ctx.providers, default=CartoDB.Positron
|
|
145
|
+
Contextily basemap provider
|
|
146
|
+
- save_path : str, optional
|
|
147
|
+
Path to save the edge GeoDataFrame as a GeoPackage
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
tuple
|
|
152
|
+
(networkx.MultiDiGraph, geopandas.GeoDataFrame)
|
|
153
|
+
The network graph with computed edge values and edge geometries as a GeoDataFrame.
|
|
154
|
+
"""
|
|
155
|
+
defaults = {
|
|
156
|
+
'network_type': 'walk',
|
|
157
|
+
'vis_graph': True,
|
|
158
|
+
'colormap': 'viridis',
|
|
159
|
+
'vmin': None,
|
|
160
|
+
'vmax': None,
|
|
161
|
+
'edge_width': 1,
|
|
162
|
+
'fig_size': (15,15),
|
|
163
|
+
'zoom': 16,
|
|
164
|
+
'basemap_style': ctx.providers.CartoDB.Positron,
|
|
165
|
+
'save_path': None
|
|
166
|
+
}
|
|
167
|
+
settings = {**defaults, **kwargs}
|
|
168
|
+
|
|
169
|
+
# Derive geometry parameters from VoxCity if supplied (inline to avoid extra helper)
|
|
170
|
+
if voxcity is not None:
|
|
171
|
+
derived_rv = None
|
|
172
|
+
derived_meshsize = None
|
|
173
|
+
# Try extras['rectangle_vertices'] when available
|
|
174
|
+
if hasattr(voxcity, "extras") and isinstance(voxcity.extras, dict):
|
|
175
|
+
derived_rv = voxcity.extras.get("rectangle_vertices")
|
|
176
|
+
# Pull meshsize and bounds from voxels.meta
|
|
177
|
+
voxels = getattr(voxcity, "voxels", None)
|
|
178
|
+
meta = getattr(voxels, "meta", None) if voxels is not None else None
|
|
179
|
+
if meta is not None:
|
|
180
|
+
derived_meshsize = getattr(meta, "meshsize", None)
|
|
181
|
+
if derived_rv is None:
|
|
182
|
+
bounds = getattr(meta, "bounds", None)
|
|
183
|
+
if bounds is not None:
|
|
184
|
+
west, south, east, north = bounds
|
|
185
|
+
derived_rv = [(west, south), (west, north), (east, north), (east, south)]
|
|
186
|
+
if rectangle_vertices is None:
|
|
187
|
+
rectangle_vertices = derived_rv
|
|
188
|
+
if meshsize is None:
|
|
189
|
+
meshsize = derived_meshsize
|
|
190
|
+
|
|
191
|
+
if rectangle_vertices is None:
|
|
192
|
+
raise ValueError("rectangle_vertices must be provided, either directly or via `voxcity`.")
|
|
193
|
+
|
|
194
|
+
# Build polygons GDF if needed
|
|
195
|
+
polygons_gdf = (grid if isinstance(grid, gpd.GeoDataFrame)
|
|
196
|
+
else grid_to_geodataframe(grid, rectangle_vertices, meshsize))
|
|
197
|
+
if polygons_gdf.crs is None:
|
|
198
|
+
polygons_gdf.set_crs(epsg=4326, inplace=True)
|
|
199
|
+
|
|
200
|
+
# BBox
|
|
201
|
+
north, south = rectangle_vertices[1][1], rectangle_vertices[0][1]
|
|
202
|
+
east, west = rectangle_vertices[2][0], rectangle_vertices[0][0]
|
|
203
|
+
bbox = (west, south, east, north)
|
|
204
|
+
|
|
205
|
+
# Download OSMnx network
|
|
206
|
+
G = ox.graph.graph_from_bbox(
|
|
207
|
+
bbox=bbox,
|
|
208
|
+
network_type=settings['network_type'],
|
|
209
|
+
simplify=True
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Compute edge values with the vectorized function
|
|
213
|
+
edge_values = vectorized_edge_values(G, polygons_gdf, value_col="value")
|
|
214
|
+
nx.set_edge_attributes(G, edge_values, name=value_name)
|
|
215
|
+
|
|
216
|
+
# Build edge GDF
|
|
217
|
+
edges_with_values = []
|
|
218
|
+
for u, v, k, data in G.edges(data=True, keys=True):
|
|
219
|
+
if 'geometry' in data:
|
|
220
|
+
geom = data['geometry']
|
|
221
|
+
else:
|
|
222
|
+
start_node = G.nodes[u]
|
|
223
|
+
end_node = G.nodes[v]
|
|
224
|
+
geom = LineString([(start_node['x'], start_node['y']),
|
|
225
|
+
(end_node['x'], end_node['y'])])
|
|
226
|
+
|
|
227
|
+
val = data.get(value_name, np.nan)
|
|
228
|
+
edges_with_values.append({
|
|
229
|
+
'u': u, 'v': v, 'key': k,
|
|
230
|
+
'geometry': geom,
|
|
231
|
+
value_name: val
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
|
|
235
|
+
|
|
236
|
+
# Save
|
|
237
|
+
if settings['save_path']:
|
|
238
|
+
edge_gdf.to_file(settings['save_path'], driver="GPKG")
|
|
239
|
+
|
|
240
|
+
if settings['vis_graph']:
|
|
241
|
+
edge_gdf_web = edge_gdf.to_crs(epsg=3857)
|
|
242
|
+
fig, ax = plt.subplots(figsize=settings['fig_size'])
|
|
243
|
+
edge_gdf_web.plot(
|
|
244
|
+
column=value_name,
|
|
245
|
+
ax=ax,
|
|
246
|
+
cmap=settings['colormap'],
|
|
247
|
+
legend=True,
|
|
248
|
+
vmin=settings['vmin'],
|
|
249
|
+
vmax=settings['vmax'],
|
|
250
|
+
linewidth=settings['edge_width'],
|
|
251
|
+
legend_kwds={'label': value_name, 'shrink': 0.5}
|
|
252
|
+
)
|
|
253
|
+
ctx.add_basemap(ax, source=settings['basemap_style'], zoom=settings['zoom'])
|
|
254
|
+
ax.set_axis_off()
|
|
255
|
+
plt.show()
|
|
256
|
+
|
|
257
|
+
return G, edge_gdf
|
|
258
|
+
|
|
259
|
+
# -------------------------------------------------------------------
|
|
260
|
+
# 1) Functions for interpolation, parallelization, and slope
|
|
261
|
+
# -------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def interpolate_points_along_line(line, interval):
|
|
264
|
+
"""
|
|
265
|
+
Interpolate points along a single LineString at a given interval (in meters).
|
|
266
|
+
If the line is shorter than `interval`, only start/end points are returned.
|
|
267
|
+
|
|
268
|
+
This function handles coordinate system transformations to ensure accurate
|
|
269
|
+
distance measurements, working in Web Mercator (EPSG:3857) for distance
|
|
270
|
+
calculations while maintaining WGS84 (EPSG:4326) for input/output.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
line : shapely.geometry.LineString
|
|
275
|
+
Edge geometry in EPSG:4326 (lon/lat).
|
|
276
|
+
interval : float
|
|
277
|
+
Distance in meters between interpolated points.
|
|
278
|
+
|
|
279
|
+
Returns
|
|
280
|
+
-------
|
|
281
|
+
list of shapely.geometry.Point
|
|
282
|
+
Points in EPSG:4326 along the line, spaced approximately `interval` meters apart.
|
|
283
|
+
For lines shorter than interval, only start and end points are returned.
|
|
284
|
+
For empty lines, an empty list is returned.
|
|
285
|
+
"""
|
|
286
|
+
if line.is_empty:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
# Transformers for metric distance calculations
|
|
290
|
+
project = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
|
|
291
|
+
project_rev = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True).transform
|
|
292
|
+
|
|
293
|
+
# Project line to Web Mercator
|
|
294
|
+
line_merc = shapely.ops.transform(project, line)
|
|
295
|
+
length_m = line_merc.length
|
|
296
|
+
if length_m == 0:
|
|
297
|
+
return [Point(line.coords[0])]
|
|
298
|
+
|
|
299
|
+
# If line is shorter than interval, just start & end
|
|
300
|
+
if length_m < interval:
|
|
301
|
+
return [Point(line.coords[0]), Point(line.coords[-1])]
|
|
302
|
+
|
|
303
|
+
# Otherwise, create distances
|
|
304
|
+
num_points = int(length_m // interval)
|
|
305
|
+
dists = [i * interval for i in range(num_points + 1)]
|
|
306
|
+
# Ensure end
|
|
307
|
+
if dists[-1] < length_m:
|
|
308
|
+
dists.append(length_m)
|
|
309
|
+
|
|
310
|
+
# Interpolate
|
|
311
|
+
points_merc = [line_merc.interpolate(d) for d in dists]
|
|
312
|
+
# Reproject back
|
|
313
|
+
return [shapely.ops.transform(project_rev, pt) for pt in points_merc]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def gather_interpolation_points(G, interval=10.0, n_jobs=1):
|
|
317
|
+
"""
|
|
318
|
+
Gather all interpolation points for each edge in the graph into a single GeoDataFrame.
|
|
319
|
+
Supports parallel processing for improved performance on large networks.
|
|
320
|
+
|
|
321
|
+
This function processes each edge in the graph, either using its geometry attribute
|
|
322
|
+
or creating a LineString from node coordinates, then interpolates points along it
|
|
323
|
+
at the specified interval.
|
|
324
|
+
|
|
325
|
+
Parameters
|
|
326
|
+
----------
|
|
327
|
+
G : networkx.MultiDiGraph
|
|
328
|
+
OSMnx graph with 'geometry' attributes or x,y coordinates in the nodes.
|
|
329
|
+
interval : float, default=10.0
|
|
330
|
+
Interpolation distance interval in meters.
|
|
331
|
+
n_jobs : int, default=1
|
|
332
|
+
Number of parallel jobs for processing edges. Set to 1 for sequential processing,
|
|
333
|
+
or -1 to use all available CPU cores.
|
|
334
|
+
|
|
335
|
+
Returns
|
|
336
|
+
-------
|
|
337
|
+
gpd.GeoDataFrame
|
|
338
|
+
GeoDataFrame in EPSG:4326 with columns:
|
|
339
|
+
- edge_id: Index of the edge in the graph
|
|
340
|
+
- index_in_edge: Position of the point along its edge
|
|
341
|
+
- geometry: Point geometry
|
|
342
|
+
"""
|
|
343
|
+
edges = list(G.edges(keys=True, data=True))
|
|
344
|
+
|
|
345
|
+
def process_edge(u, v, k, data, idx):
|
|
346
|
+
if 'geometry' in data:
|
|
347
|
+
line = data['geometry']
|
|
348
|
+
else:
|
|
349
|
+
# If no geometry, build from node coords
|
|
350
|
+
start_node = G.nodes[u]
|
|
351
|
+
end_node = G.nodes[v]
|
|
352
|
+
line = LineString([(start_node['x'], start_node['y']),
|
|
353
|
+
(end_node['x'], end_node['y'])])
|
|
354
|
+
|
|
355
|
+
pts = interpolate_points_along_line(line, interval)
|
|
356
|
+
df = pd.DataFrame({
|
|
357
|
+
'edge_id': [idx]*len(pts),
|
|
358
|
+
'index_in_edge': np.arange(len(pts)),
|
|
359
|
+
'geometry': pts
|
|
360
|
+
})
|
|
361
|
+
return df
|
|
362
|
+
|
|
363
|
+
# Parallel interpolation
|
|
364
|
+
results = Parallel(n_jobs=n_jobs, backend='threading')(
|
|
365
|
+
delayed(process_edge)(u, v, k, data, i)
|
|
366
|
+
for i, (u, v, k, data) in enumerate(edges)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
all_points_df = pd.concat(results, ignore_index=True)
|
|
370
|
+
points_gdf = gpd.GeoDataFrame(all_points_df, geometry='geometry', crs="EPSG:4326")
|
|
371
|
+
return points_gdf
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value'):
|
|
375
|
+
"""
|
|
376
|
+
Perform a spatial join to fetch DEM elevations for interpolated points.
|
|
377
|
+
|
|
378
|
+
Uses nearest neighbor matching in projected coordinates (EPSG:3857) to ensure
|
|
379
|
+
accurate distance calculations when finding the closest DEM cell for each point.
|
|
380
|
+
|
|
381
|
+
Parameters
|
|
382
|
+
----------
|
|
383
|
+
points_gdf_3857 : gpd.GeoDataFrame
|
|
384
|
+
Interpolation points in EPSG:3857 projection.
|
|
385
|
+
dem_gdf_3857 : gpd.GeoDataFrame
|
|
386
|
+
DEM polygons in EPSG:3857 projection, containing elevation values.
|
|
387
|
+
elevation_col : str, default='value'
|
|
388
|
+
Name of the column containing elevation values in dem_gdf_3857.
|
|
389
|
+
|
|
390
|
+
Returns
|
|
391
|
+
-------
|
|
392
|
+
gpd.GeoDataFrame
|
|
393
|
+
Copy of points_gdf_3857 with additional columns:
|
|
394
|
+
- elevation: Elevation value from nearest DEM cell
|
|
395
|
+
- dist_to_poly: Distance to nearest DEM cell
|
|
396
|
+
"""
|
|
397
|
+
joined = gpd.sjoin_nearest(
|
|
398
|
+
points_gdf_3857,
|
|
399
|
+
dem_gdf_3857[[elevation_col, 'geometry']].copy(),
|
|
400
|
+
how='left',
|
|
401
|
+
distance_col='dist_to_poly'
|
|
402
|
+
)
|
|
403
|
+
joined.rename(columns={elevation_col: 'elevation'}, inplace=True)
|
|
404
|
+
return joined
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def compute_slope_for_group(df):
|
|
408
|
+
"""
|
|
409
|
+
Compute average slope between consecutive points along a single edge.
|
|
410
|
+
|
|
411
|
+
Slopes are calculated as absolute percentage grade (rise/run * 100) between
|
|
412
|
+
consecutive points, then averaged for the entire edge. Points must be in
|
|
413
|
+
EPSG:3857 projection for accurate horizontal distance calculations.
|
|
414
|
+
|
|
415
|
+
Parameters
|
|
416
|
+
----------
|
|
417
|
+
df : pd.DataFrame
|
|
418
|
+
DataFrame containing points for a single edge with columns:
|
|
419
|
+
- geometry: Point geometries in EPSG:3857
|
|
420
|
+
- elevation: Elevation values in meters
|
|
421
|
+
- index_in_edge: Position along the edge for sorting
|
|
422
|
+
|
|
423
|
+
Returns
|
|
424
|
+
-------
|
|
425
|
+
float
|
|
426
|
+
Average slope as a percentage, or np.nan if no valid slopes can be computed
|
|
427
|
+
(e.g., when points are coincident or no elevation change).
|
|
428
|
+
"""
|
|
429
|
+
# Sort by position along the edge
|
|
430
|
+
df = df.sort_values("index_in_edge")
|
|
431
|
+
|
|
432
|
+
# Coordinates
|
|
433
|
+
xs = df.geometry.x.to_numpy()
|
|
434
|
+
ys = df.geometry.y.to_numpy()
|
|
435
|
+
elevs = df["elevation"].to_numpy()
|
|
436
|
+
|
|
437
|
+
# Differences
|
|
438
|
+
dx = np.diff(xs)
|
|
439
|
+
dy = np.diff(ys)
|
|
440
|
+
horizontal_dist = np.sqrt(dx**2 + dy**2)
|
|
441
|
+
elev_diff = np.diff(elevs)
|
|
442
|
+
|
|
443
|
+
# Slope in %
|
|
444
|
+
valid_mask = horizontal_dist > 0
|
|
445
|
+
slopes = (np.abs(elev_diff[valid_mask]) / horizontal_dist[valid_mask]) * 100
|
|
446
|
+
|
|
447
|
+
if len(slopes) == 0:
|
|
448
|
+
return np.nan
|
|
449
|
+
return slopes.mean()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def calculate_edge_slopes_from_join(joined_points_gdf, n_edges):
|
|
453
|
+
"""
|
|
454
|
+
Calculate average slopes for all edges in the network from interpolated points.
|
|
455
|
+
|
|
456
|
+
This function groups points by edge_id and computes the average slope for each edge
|
|
457
|
+
using the compute_slope_for_group function. It ensures all edges in the original
|
|
458
|
+
graph have a slope value, even if no valid slope could be computed.
|
|
459
|
+
|
|
460
|
+
Parameters
|
|
461
|
+
----------
|
|
462
|
+
joined_points_gdf : gpd.GeoDataFrame
|
|
463
|
+
Points with elevations in EPSG:3857, must have columns:
|
|
464
|
+
- edge_id: Index of the edge in the graph
|
|
465
|
+
- index_in_edge: Position along the edge
|
|
466
|
+
- elevation: Elevation value
|
|
467
|
+
- geometry: Point geometry
|
|
468
|
+
n_edges : int
|
|
469
|
+
Total number of edges in the original graph.
|
|
470
|
+
|
|
471
|
+
Returns
|
|
472
|
+
-------
|
|
473
|
+
dict
|
|
474
|
+
Dictionary mapping edge_id to average slope (in %). Edges with no valid
|
|
475
|
+
slope calculation are assigned np.nan.
|
|
476
|
+
"""
|
|
477
|
+
# We'll group by edge_id, ignoring the group columns in apply (pandas >= 2.1).
|
|
478
|
+
# If your pandas version < 2.1, just do a column subset after groupby.
|
|
479
|
+
# E.g. .groupby("edge_id", group_keys=False)[["geometry","elevation","index_in_edge"]]...
|
|
480
|
+
grouped = joined_points_gdf.groupby("edge_id", group_keys=False)
|
|
481
|
+
results = grouped[["geometry", "elevation", "index_in_edge"]].apply(compute_slope_for_group)
|
|
482
|
+
|
|
483
|
+
# Convert series -> dict
|
|
484
|
+
slope_dict = results.to_dict()
|
|
485
|
+
|
|
486
|
+
# Fill any missing edge IDs with NaN
|
|
487
|
+
for i in range(n_edges):
|
|
488
|
+
if i not in slope_dict:
|
|
489
|
+
slope_dict[i] = np.nan
|
|
490
|
+
|
|
491
|
+
return slope_dict
|
|
492
|
+
|
|
493
|
+
# -------------------------------------------------------------------
|
|
494
|
+
# 2) Main function to analyze network slopes
|
|
495
|
+
# -------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def analyze_network_slopes(
|
|
498
|
+
dem_grid,
|
|
499
|
+
meshsize,
|
|
500
|
+
value_name='slope',
|
|
501
|
+
interval=10.0,
|
|
502
|
+
n_jobs=1,
|
|
503
|
+
**kwargs
|
|
504
|
+
):
|
|
505
|
+
"""
|
|
506
|
+
Analyze and visualize street network slopes using Digital Elevation Model (DEM) data.
|
|
507
|
+
|
|
508
|
+
This function performs a comprehensive analysis of street network slopes by:
|
|
509
|
+
1. Converting DEM data to a GeoDataFrame of elevation polygons
|
|
510
|
+
2. Downloading the street network from OpenStreetMap
|
|
511
|
+
3. Interpolating points along network edges
|
|
512
|
+
4. Matching points to DEM elevations
|
|
513
|
+
5. Computing slopes between consecutive points
|
|
514
|
+
6. Aggregating slopes per edge
|
|
515
|
+
7. Optionally visualizing results on an interactive map
|
|
516
|
+
|
|
517
|
+
The analysis uses appropriate coordinate transformations between WGS84 (EPSG:4326)
|
|
518
|
+
for geographic operations and Web Mercator (EPSG:3857) for distance calculations.
|
|
519
|
+
|
|
520
|
+
Parameters
|
|
521
|
+
----------
|
|
522
|
+
dem_grid : array-like
|
|
523
|
+
Digital Elevation Model grid data containing elevation values.
|
|
524
|
+
meshsize : float
|
|
525
|
+
Size of each DEM grid cell.
|
|
526
|
+
value_name : str, default='slope'
|
|
527
|
+
Name to use for the slope attribute in output data.
|
|
528
|
+
interval : float, default=10.0
|
|
529
|
+
Distance in meters between interpolated points along edges.
|
|
530
|
+
n_jobs : int, default=1
|
|
531
|
+
Number of parallel jobs for edge processing.
|
|
532
|
+
**kwargs : dict
|
|
533
|
+
Additional configuration parameters:
|
|
534
|
+
- rectangle_vertices : list of (lon, lat), required
|
|
535
|
+
Coordinates defining the analysis area in EPSG:4326
|
|
536
|
+
- network_type : str, default='walk'
|
|
537
|
+
Type of street network to download
|
|
538
|
+
- vis_graph : bool, default=True
|
|
539
|
+
Whether to create visualization
|
|
540
|
+
- colormap : str, default='viridis'
|
|
541
|
+
Matplotlib colormap for slope visualization
|
|
542
|
+
- vmin, vmax : float, optional
|
|
543
|
+
Value range for slope coloring
|
|
544
|
+
- edge_width : float, default=1
|
|
545
|
+
Width of edge lines in plot
|
|
546
|
+
- fig_size : tuple, default=(15,15)
|
|
547
|
+
Figure size in inches
|
|
548
|
+
- zoom : int, default=16
|
|
549
|
+
Zoom level for basemap
|
|
550
|
+
- basemap_style : ctx.providers, default=CartoDB.Positron
|
|
551
|
+
Contextily basemap provider
|
|
552
|
+
- output_directory : str, optional
|
|
553
|
+
Directory to save results
|
|
554
|
+
- output_file_name : str, default='network_slopes'
|
|
555
|
+
Base name for output files
|
|
556
|
+
- alpha : float, default=1.0
|
|
557
|
+
Transparency of edge lines in visualization
|
|
558
|
+
|
|
559
|
+
Returns
|
|
560
|
+
-------
|
|
561
|
+
tuple
|
|
562
|
+
(networkx.MultiDiGraph, geopandas.GeoDataFrame)
|
|
563
|
+
- Graph with slope values as edge attributes
|
|
564
|
+
- GeoDataFrame of edges with geometries and slope values
|
|
565
|
+
|
|
566
|
+
Notes
|
|
567
|
+
-----
|
|
568
|
+
- Slopes are calculated as absolute percentage grades (rise/run * 100)
|
|
569
|
+
- Edge slopes are length-weighted averages of point-to-point slopes
|
|
570
|
+
- The visualization includes a basemap and legend showing slope percentages
|
|
571
|
+
- If output_directory is specified, results are saved as a GeoPackage
|
|
572
|
+
"""
|
|
573
|
+
defaults = {
|
|
574
|
+
'rectangle_vertices': None,
|
|
575
|
+
'network_type': 'walk',
|
|
576
|
+
'vis_graph': True,
|
|
577
|
+
'colormap': 'viridis',
|
|
578
|
+
'vmin': None,
|
|
579
|
+
'vmax': None,
|
|
580
|
+
'edge_width': 1,
|
|
581
|
+
'fig_size': (15, 15),
|
|
582
|
+
'zoom': 16,
|
|
583
|
+
'basemap_style': ctx.providers.CartoDB.Positron,
|
|
584
|
+
'output_directory': None,
|
|
585
|
+
'output_file_name': 'network_slopes',
|
|
586
|
+
'alpha': 1.0
|
|
587
|
+
}
|
|
588
|
+
settings = {**defaults, **kwargs}
|
|
589
|
+
|
|
590
|
+
# Validate bounding box
|
|
591
|
+
if settings['rectangle_vertices'] is None:
|
|
592
|
+
raise ValueError("Must supply 'rectangle_vertices' in kwargs.")
|
|
593
|
+
|
|
594
|
+
# 1) Build DEM GeoDataFrame in EPSG:4326
|
|
595
|
+
dem_gdf = grid_to_geodataframe(dem_grid, settings['rectangle_vertices'], meshsize)
|
|
596
|
+
if dem_gdf.crs is None:
|
|
597
|
+
dem_gdf.set_crs(epsg=4326, inplace=True)
|
|
598
|
+
|
|
599
|
+
# 2) Download bounding box from rectangle_vertices
|
|
600
|
+
north, south = settings['rectangle_vertices'][1][1], settings['rectangle_vertices'][0][1]
|
|
601
|
+
east, west = settings['rectangle_vertices'][2][0], settings['rectangle_vertices'][0][0]
|
|
602
|
+
bbox = (west, south, east, north)
|
|
603
|
+
|
|
604
|
+
G = ox.graph.graph_from_bbox(
|
|
605
|
+
bbox=bbox,
|
|
606
|
+
network_type=settings['network_type'],
|
|
607
|
+
simplify=True
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# 3) Interpolate points along edges (EPSG:4326)
|
|
611
|
+
points_gdf_4326 = gather_interpolation_points(G, interval=interval, n_jobs=n_jobs)
|
|
612
|
+
|
|
613
|
+
# 4) Reproject DEM + Points to EPSG:3857 for correct distance operations
|
|
614
|
+
dem_gdf_3857 = dem_gdf.to_crs(epsg=3857)
|
|
615
|
+
points_gdf_3857 = points_gdf_4326.to_crs(epsg=3857)
|
|
616
|
+
|
|
617
|
+
# 5) Perform spatial join to get elevations
|
|
618
|
+
joined_points_3857 = fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value')
|
|
619
|
+
|
|
620
|
+
# 6) Compute slopes for each edge
|
|
621
|
+
n_edges = len(list(G.edges(keys=True)))
|
|
622
|
+
slope_dict = calculate_edge_slopes_from_join(joined_points_3857, n_edges)
|
|
623
|
+
|
|
624
|
+
# 7) Assign slopes back to G
|
|
625
|
+
edges = list(G.edges(keys=True, data=True))
|
|
626
|
+
edge_slopes = {}
|
|
627
|
+
for i, (u, v, k, data) in enumerate(edges):
|
|
628
|
+
edge_slopes[(u, v, k)] = slope_dict.get(i, np.nan)
|
|
629
|
+
nx.set_edge_attributes(G, edge_slopes, name=value_name)
|
|
630
|
+
|
|
631
|
+
# 8) Build an edge GeoDataFrame in EPSG:4326
|
|
632
|
+
edges_with_values = []
|
|
633
|
+
for (u, v, k, data), edge_id in zip(edges, range(len(edges))):
|
|
634
|
+
if 'geometry' in data:
|
|
635
|
+
geom = data['geometry']
|
|
636
|
+
else:
|
|
637
|
+
start_node = G.nodes[u]
|
|
638
|
+
end_node = G.nodes[v]
|
|
639
|
+
geom = LineString([(start_node['x'], start_node['y']),
|
|
640
|
+
(end_node['x'], end_node['y'])])
|
|
641
|
+
|
|
642
|
+
edges_with_values.append({
|
|
643
|
+
'u': u,
|
|
644
|
+
'v': v,
|
|
645
|
+
'key': k,
|
|
646
|
+
'geometry': geom,
|
|
647
|
+
value_name: slope_dict.get(edge_id, np.nan)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
|
|
651
|
+
|
|
652
|
+
# 9) Save output if requested
|
|
653
|
+
if settings['output_directory']:
|
|
654
|
+
os.makedirs(settings['output_directory'], exist_ok=True)
|
|
655
|
+
out_path = os.path.join(
|
|
656
|
+
settings['output_directory'],
|
|
657
|
+
f"{settings['output_file_name']}.gpkg"
|
|
658
|
+
)
|
|
659
|
+
edge_gdf.to_file(out_path, driver="GPKG")
|
|
660
|
+
|
|
661
|
+
# 10) Visualization
|
|
662
|
+
if settings['vis_graph']:
|
|
663
|
+
# Create a Polygon from the rectangle vertices
|
|
664
|
+
rectangle_polygon = Polygon(settings['rectangle_vertices'])
|
|
665
|
+
|
|
666
|
+
# Convert the rectangle polygon to the same CRS as edge_gdf_web
|
|
667
|
+
rectangle_gdf = gpd.GeoDataFrame(index=[0], crs='epsg:4326', geometry=[rectangle_polygon])
|
|
668
|
+
rectangle_gdf_web = rectangle_gdf.to_crs(epsg=3857)
|
|
669
|
+
|
|
670
|
+
# Get the bounding box of the rectangle
|
|
671
|
+
minx, miny, maxx, maxy = rectangle_gdf_web.total_bounds
|
|
672
|
+
|
|
673
|
+
# Plot the edges
|
|
674
|
+
edge_gdf_web = edge_gdf.to_crs(epsg=3857)
|
|
675
|
+
fig, ax = plt.subplots(figsize=settings['fig_size'])
|
|
676
|
+
edge_gdf_web.plot(
|
|
677
|
+
column=value_name,
|
|
678
|
+
ax=ax,
|
|
679
|
+
cmap=settings['colormap'],
|
|
680
|
+
legend=True,
|
|
681
|
+
vmin=settings['vmin'],
|
|
682
|
+
vmax=settings['vmax'],
|
|
683
|
+
linewidth=settings['edge_width'],
|
|
684
|
+
alpha=settings['alpha'],
|
|
685
|
+
legend_kwds={'label': f"{value_name} (%)"}
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Add basemap with the same extent as the rectangle
|
|
689
|
+
ctx.add_basemap(
|
|
690
|
+
ax,
|
|
691
|
+
source=settings['basemap_style'],
|
|
692
|
+
zoom=settings['zoom'],
|
|
693
|
+
bounds=(minx, miny, maxx, maxy) # Explicitly set the bounds of the basemap
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
# Set the plot limits to the bounding box of the rectangle
|
|
697
|
+
ax.set_xlim(minx, maxx)
|
|
698
|
+
ax.set_ylim(miny, maxy)
|
|
699
|
+
|
|
700
|
+
# Turn off the axis
|
|
701
|
+
ax.set_axis_off()
|
|
702
|
+
|
|
703
|
+
# Add title
|
|
704
|
+
plt.title(f'Network {value_name} Analysis', pad=20)
|
|
705
|
+
|
|
706
|
+
# Show the plot
|
|
707
|
+
plt.show()
|
|
708
|
+
|
|
709
709
|
return G, edge_gdf
|