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