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.
Files changed (42) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/exporter/__init__.py +12 -12
  3. voxcity/exporter/cityles.py +633 -633
  4. voxcity/exporter/envimet.py +733 -728
  5. voxcity/exporter/magicavoxel.py +333 -333
  6. voxcity/exporter/netcdf.py +238 -238
  7. voxcity/exporter/obj.py +1480 -1480
  8. voxcity/generator/__init__.py +47 -44
  9. voxcity/generator/api.py +721 -675
  10. voxcity/generator/grids.py +381 -379
  11. voxcity/generator/io.py +94 -94
  12. voxcity/generator/pipeline.py +282 -282
  13. voxcity/generator/update.py +429 -0
  14. voxcity/generator/voxelizer.py +18 -6
  15. voxcity/geoprocessor/__init__.py +75 -75
  16. voxcity/geoprocessor/draw.py +1488 -1219
  17. voxcity/geoprocessor/merge_utils.py +91 -91
  18. voxcity/geoprocessor/mesh.py +806 -806
  19. voxcity/geoprocessor/network.py +708 -708
  20. voxcity/geoprocessor/raster/buildings.py +435 -428
  21. voxcity/geoprocessor/raster/export.py +93 -93
  22. voxcity/geoprocessor/raster/landcover.py +5 -2
  23. voxcity/geoprocessor/utils.py +824 -824
  24. voxcity/models.py +113 -113
  25. voxcity/simulator/solar/__init__.py +66 -43
  26. voxcity/simulator/solar/integration.py +336 -336
  27. voxcity/simulator/solar/sky.py +668 -0
  28. voxcity/simulator/solar/temporal.py +792 -434
  29. voxcity/utils/__init__.py +11 -0
  30. voxcity/utils/classes.py +194 -0
  31. voxcity/utils/lc.py +80 -39
  32. voxcity/utils/shape.py +230 -0
  33. voxcity/visualizer/__init__.py +24 -24
  34. voxcity/visualizer/builder.py +43 -43
  35. voxcity/visualizer/grids.py +141 -141
  36. voxcity/visualizer/maps.py +187 -187
  37. voxcity/visualizer/renderer.py +1145 -928
  38. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/METADATA +90 -49
  39. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/RECORD +42 -38
  40. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  41. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  42. {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -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