voxcity 0.3.8__py3-none-any.whl → 0.3.9__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.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

voxcity/geo/network.py CHANGED
@@ -6,6 +6,12 @@ import geopandas as gpd
6
6
  from shapely.geometry import LineString
7
7
  import networkx as nx
8
8
  import osmnx as ox
9
+ import os
10
+ import shapely
11
+ from shapely.geometry import Point
12
+ from shapely.ops import transform
13
+ from pyproj import Transformer
14
+ from joblib import Parallel, delayed
9
15
 
10
16
  from .grid import grid_to_geodataframe
11
17
 
@@ -191,4 +197,345 @@ def get_network_values(grid, rectangle_vertices, meshsize, value_name='value', *
191
197
  # plt.title(f'Network {value_name} Analysis', pad=20)
192
198
  plt.show()
193
199
 
200
+ return G, edge_gdf
201
+
202
+ # -------------------------------------------------------------------
203
+ # Optionally import your DEM helper
204
+ # -------------------------------------------------------------------
205
+ from voxcity.geo.grid import grid_to_geodataframe
206
+
207
+ # -------------------------------------------------------------------
208
+ # 1) Functions for interpolation, parallelization, and slope
209
+ # -------------------------------------------------------------------
210
+
211
+ def interpolate_points_along_line(line, interval):
212
+ """
213
+ Interpolate points along a single LineString at a given interval (in meters).
214
+ If the line is shorter than `interval`, only start/end points are returned.
215
+
216
+ Parameters
217
+ ----------
218
+ line : shapely.geometry.LineString
219
+ Edge geometry in EPSG:4326 (lon/lat).
220
+ interval : float
221
+ Distance in meters between interpolated points.
222
+
223
+ Returns
224
+ -------
225
+ list of shapely.geometry.Point
226
+ Points in EPSG:4326 along the line.
227
+ """
228
+ if line.is_empty:
229
+ return []
230
+
231
+ # Transformers for metric distance calculations
232
+ project = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
233
+ project_rev = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True).transform
234
+
235
+ # Project line to Web Mercator
236
+ line_merc = shapely.ops.transform(project, line)
237
+ length_m = line_merc.length
238
+ if length_m == 0:
239
+ return [Point(line.coords[0])]
240
+
241
+ # If line is shorter than interval, just start & end
242
+ if length_m < interval:
243
+ return [Point(line.coords[0]), Point(line.coords[-1])]
244
+
245
+ # Otherwise, create distances
246
+ num_points = int(length_m // interval)
247
+ dists = [i * interval for i in range(num_points + 1)]
248
+ # Ensure end
249
+ if dists[-1] < length_m:
250
+ dists.append(length_m)
251
+
252
+ # Interpolate
253
+ points_merc = [line_merc.interpolate(d) for d in dists]
254
+ # Reproject back
255
+ return [shapely.ops.transform(project_rev, pt) for pt in points_merc]
256
+
257
+
258
+ def gather_interpolation_points(G, interval=10.0, n_jobs=1):
259
+ """
260
+ Gather all interpolation points for each edge in the graph into a single GeoDataFrame.
261
+ Can be parallelized with `n_jobs`.
262
+
263
+ Parameters
264
+ ----------
265
+ G : networkx.MultiDiGraph
266
+ OSMnx graph with 'geometry' attributes or x,y coordinates in the nodes.
267
+ interval : float, default=10.0
268
+ Interpolation distance interval in meters.
269
+ n_jobs : int, default=1
270
+ Number of parallel jobs (1 => no parallelization).
271
+
272
+ Returns
273
+ -------
274
+ gpd.GeoDataFrame
275
+ Columns: edge_id, index_in_edge, geometry (EPSG:4326).
276
+ """
277
+ edges = list(G.edges(keys=True, data=True))
278
+
279
+ def process_edge(u, v, k, data, idx):
280
+ if 'geometry' in data:
281
+ line = data['geometry']
282
+ else:
283
+ # If no geometry, build from node coords
284
+ start_node = G.nodes[u]
285
+ end_node = G.nodes[v]
286
+ line = LineString([(start_node['x'], start_node['y']),
287
+ (end_node['x'], end_node['y'])])
288
+
289
+ pts = interpolate_points_along_line(line, interval)
290
+ df = pd.DataFrame({
291
+ 'edge_id': [idx]*len(pts),
292
+ 'index_in_edge': np.arange(len(pts)),
293
+ 'geometry': pts
294
+ })
295
+ return df
296
+
297
+ # Parallel interpolation
298
+ results = Parallel(n_jobs=n_jobs, backend='threading')(
299
+ delayed(process_edge)(u, v, k, data, i)
300
+ for i, (u, v, k, data) in enumerate(edges)
301
+ )
302
+
303
+ all_points_df = pd.concat(results, ignore_index=True)
304
+ points_gdf = gpd.GeoDataFrame(all_points_df, geometry='geometry', crs="EPSG:4326")
305
+ return points_gdf
306
+
307
+
308
+ def fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value'):
309
+ """
310
+ Do a spatial join (nearest) in a projected CRS (EPSG:3857) to fetch DEM elevations.
311
+
312
+ Parameters
313
+ ----------
314
+ points_gdf_3857 : gpd.GeoDataFrame
315
+ Interpolation points in EPSG:3857.
316
+ dem_gdf_3857 : gpd.GeoDataFrame
317
+ DEM polygons in EPSG:3857, must have `elevation_col`.
318
+ elevation_col : str, default='value'
319
+ Column with elevation values in dem_gdf_3857.
320
+
321
+ Returns
322
+ -------
323
+ gpd.GeoDataFrame
324
+ A copy of points_gdf_3857 with new column 'elevation'.
325
+ """
326
+ joined = gpd.sjoin_nearest(
327
+ points_gdf_3857,
328
+ dem_gdf_3857[[elevation_col, 'geometry']].copy(),
329
+ how='left',
330
+ distance_col='dist_to_poly'
331
+ )
332
+ joined.rename(columns={elevation_col: 'elevation'}, inplace=True)
333
+ return joined
334
+
335
+
336
+ def compute_slope_for_group(df):
337
+ """
338
+ Given a subset of points for a single edge, compute average slope between
339
+ consecutive points, using columns: geometry, elevation, index_in_edge.
340
+
341
+ Note: We assume df is already in EPSG:3857 for direct distance calculations.
342
+ """
343
+ # Sort by position along the edge
344
+ df = df.sort_values("index_in_edge")
345
+
346
+ # Coordinates
347
+ xs = df.geometry.x.to_numpy()
348
+ ys = df.geometry.y.to_numpy()
349
+ elevs = df["elevation"].to_numpy()
350
+
351
+ # Differences
352
+ dx = np.diff(xs)
353
+ dy = np.diff(ys)
354
+ horizontal_dist = np.sqrt(dx**2 + dy**2)
355
+ elev_diff = np.diff(elevs)
356
+
357
+ # Slope in %
358
+ valid_mask = horizontal_dist > 0
359
+ slopes = (np.abs(elev_diff[valid_mask]) / horizontal_dist[valid_mask]) * 100
360
+
361
+ if len(slopes) == 0:
362
+ return np.nan
363
+ return slopes.mean()
364
+
365
+
366
+ def calculate_edge_slopes_from_join(joined_points_gdf, n_edges):
367
+ """
368
+ Calculate average slopes for each edge by grouping joined points.
369
+
370
+ Parameters
371
+ ----------
372
+ joined_points_gdf : gpd.GeoDataFrame
373
+ Must have columns: edge_id, index_in_edge, elevation, geometry (EPSG:3857).
374
+ n_edges : int
375
+ Number of edges from the graph.
376
+
377
+ Returns
378
+ -------
379
+ dict
380
+ edge_id -> average slope (in %).
381
+ """
382
+ # We'll group by edge_id, ignoring the group columns in apply (pandas >= 2.1).
383
+ # If your pandas version < 2.1, just do a column subset after groupby.
384
+ # E.g. .groupby("edge_id", group_keys=False)[["geometry","elevation","index_in_edge"]]...
385
+ grouped = joined_points_gdf.groupby("edge_id", group_keys=False)
386
+ results = grouped[["geometry", "elevation", "index_in_edge"]].apply(compute_slope_for_group)
387
+
388
+ # Convert series -> dict
389
+ slope_dict = results.to_dict()
390
+
391
+ # Fill any missing edge IDs with NaN
392
+ for i in range(n_edges):
393
+ if i not in slope_dict:
394
+ slope_dict[i] = np.nan
395
+
396
+ return slope_dict
397
+
398
+ # -------------------------------------------------------------------
399
+ # 2) Main function to analyze network slopes
400
+ # -------------------------------------------------------------------
401
+
402
+ def analyze_network_slopes(
403
+ dem_grid,
404
+ meshsize,
405
+ value_name='slope',
406
+ interval=10.0,
407
+ n_jobs=1,
408
+ **kwargs
409
+ ):
410
+ """
411
+ Analyze and visualize network slopes based on DEM data, using vectorized + parallel methods.
412
+
413
+ Parameters
414
+ ----------
415
+ dem_grid : array-like
416
+ DEM grid data.
417
+ meshsize : float
418
+ Mesh grid size.
419
+ value_name : str, default='slope'
420
+ Column name for slopes assigned to each edge.
421
+ interval : float, default=10.0
422
+ Interpolation distance in meters.
423
+ n_jobs : int, default=1
424
+ Parallelization for edge interpolation (1 => sequential).
425
+ **kwargs : dict
426
+ Additional parameters:
427
+ - rectangle_vertices : list of (x, y) in EPSG:4326
428
+ - network_type : str, default='walk'
429
+ - vis_graph : bool, default=True
430
+ - colormap, vmin, vmax, edge_width, fig_size, zoom, basemap_style, alpha
431
+ - output_directory, output_file_name
432
+ """
433
+ defaults = {
434
+ 'rectangle_vertices': None,
435
+ 'network_type': 'walk',
436
+ 'vis_graph': True,
437
+ 'colormap': 'viridis',
438
+ 'vmin': None,
439
+ 'vmax': None,
440
+ 'edge_width': 1,
441
+ 'fig_size': (15, 15),
442
+ 'zoom': 16,
443
+ 'basemap_style': ctx.providers.CartoDB.Positron,
444
+ 'output_directory': None,
445
+ 'output_file_name': 'network_slopes',
446
+ 'alpha': 1.0
447
+ }
448
+ settings = {**defaults, **kwargs}
449
+
450
+ # Validate bounding box
451
+ if settings['rectangle_vertices'] is None:
452
+ raise ValueError("Must supply 'rectangle_vertices' in kwargs.")
453
+
454
+ # 1) Build DEM GeoDataFrame in EPSG:4326
455
+ dem_gdf = grid_to_geodataframe(dem_grid, settings['rectangle_vertices'], meshsize)
456
+ if dem_gdf.crs is None:
457
+ dem_gdf.set_crs(epsg=4326, inplace=True)
458
+
459
+ # 2) Download bounding box from rectangle_vertices
460
+ north, south = settings['rectangle_vertices'][1][1], settings['rectangle_vertices'][0][1]
461
+ east, west = settings['rectangle_vertices'][2][0], settings['rectangle_vertices'][0][0]
462
+ bbox = (west, south, east, north)
463
+
464
+ G = ox.graph.graph_from_bbox(
465
+ bbox=bbox,
466
+ network_type=settings['network_type'],
467
+ simplify=True
468
+ )
469
+
470
+ # 3) Interpolate points along edges (EPSG:4326)
471
+ points_gdf_4326 = gather_interpolation_points(G, interval=interval, n_jobs=n_jobs)
472
+
473
+ # 4) Reproject DEM + Points to EPSG:3857 for correct distance operations
474
+ dem_gdf_3857 = dem_gdf.to_crs(epsg=3857)
475
+ points_gdf_3857 = points_gdf_4326.to_crs(epsg=3857)
476
+
477
+ # 5) Perform spatial join to get elevations
478
+ joined_points_3857 = fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value')
479
+
480
+ # 6) Compute slopes for each edge
481
+ n_edges = len(list(G.edges(keys=True)))
482
+ slope_dict = calculate_edge_slopes_from_join(joined_points_3857, n_edges)
483
+
484
+ # 7) Assign slopes back to G
485
+ edges = list(G.edges(keys=True, data=True))
486
+ edge_slopes = {}
487
+ for i, (u, v, k, data) in enumerate(edges):
488
+ edge_slopes[(u, v, k)] = slope_dict.get(i, np.nan)
489
+ nx.set_edge_attributes(G, edge_slopes, name=value_name)
490
+
491
+ # 8) Build an edge GeoDataFrame in EPSG:4326
492
+ edges_with_values = []
493
+ for (u, v, k, data), edge_id in zip(edges, range(len(edges))):
494
+ if 'geometry' in data:
495
+ geom = data['geometry']
496
+ else:
497
+ start_node = G.nodes[u]
498
+ end_node = G.nodes[v]
499
+ geom = LineString([(start_node['x'], start_node['y']),
500
+ (end_node['x'], end_node['y'])])
501
+
502
+ edges_with_values.append({
503
+ 'u': u,
504
+ 'v': v,
505
+ 'key': k,
506
+ 'geometry': geom,
507
+ value_name: slope_dict.get(edge_id, np.nan)
508
+ })
509
+
510
+ edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
511
+
512
+ # 9) Save output if requested
513
+ if settings['output_directory']:
514
+ os.makedirs(settings['output_directory'], exist_ok=True)
515
+ out_path = os.path.join(
516
+ settings['output_directory'],
517
+ f"{settings['output_file_name']}.gpkg"
518
+ )
519
+ edge_gdf.to_file(out_path, driver="GPKG")
520
+
521
+ # 10) Visualization
522
+ if settings['vis_graph']:
523
+ edge_gdf_web = edge_gdf.to_crs(epsg=3857)
524
+ fig, ax = plt.subplots(figsize=settings['fig_size'])
525
+ edge_gdf_web.plot(
526
+ column=value_name,
527
+ ax=ax,
528
+ cmap=settings['colormap'],
529
+ legend=True,
530
+ vmin=settings['vmin'],
531
+ vmax=settings['vmax'],
532
+ linewidth=settings['edge_width'],
533
+ alpha=settings['alpha'],
534
+ legend_kwds={'label': f"{value_name} (%)"}
535
+ )
536
+ ctx.add_basemap(ax, source=settings['basemap_style'], zoom=settings['zoom'])
537
+ ax.set_axis_off()
538
+ plt.title(f'Network {value_name} Analysis', pad=20)
539
+ plt.show()
540
+
194
541
  return G, edge_gdf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: voxcity
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
5
5
  Author-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
6
6
  Maintainer-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
@@ -49,6 +49,7 @@ Requires-Dist: protobuf==3.20.3
49
49
  Requires-Dist: timezonefinder
50
50
  Requires-Dist: astral
51
51
  Requires-Dist: osmnx
52
+ Requires-Dist: joblib
52
53
  Provides-Extra: dev
53
54
  Requires-Dist: coverage; extra == "dev"
54
55
  Requires-Dist: mypy; extra == "dev"
@@ -17,7 +17,7 @@ voxcity/file/obj.py,sha256=oW-kPoZj53nfmO9tXP3Wvizq6Kkjh-QQR8UBexRuMiI,21609
17
17
  voxcity/geo/__init_.py,sha256=AZYQxK1zY1M_mDT1HmgcdVI86OAtwK7CNo3AOScLHco,88
18
18
  voxcity/geo/draw.py,sha256=roljWXyqYdsWYkmb-5_WNxrJrfV5lnAt8uZblCCo_3Q,13555
19
19
  voxcity/geo/grid.py,sha256=_MzO-Cu2GhlP9nuCql6f1pfbU2_OAL27aQ_zCj1u_zk,36288
20
- voxcity/geo/network.py,sha256=iBgvOaM4YPQKL5gnAU9rxe3ZlJLTjLIt7DoAIWzZRfs,6892
20
+ voxcity/geo/network.py,sha256=lcDLgsmPb9MyFeQlJwscXl7_9JCG7TIlbAu19MPf2m8,18846
21
21
  voxcity/geo/utils.py,sha256=1BRHp-DDeOA8HG8jplY7Eo75G3oXkVGL6DGONL4BA8A,19815
22
22
  voxcity/sim/__init_.py,sha256=APdkcdaovj0v_RPOaA4SBvFUKT2RM7Hxuuz3Sux4gCo,65
23
23
  voxcity/sim/solar.py,sha256=f9GLANRnEVj7NseSETVRDvTD_t_Bn9hC6dJUV5Ak_cU,31799
@@ -28,9 +28,9 @@ voxcity/utils/lc.py,sha256=RwPd-VY3POV3gTrBhM7TubgGb9MCd3nVah_G8iUEF7k,11562
28
28
  voxcity/utils/material.py,sha256=Vt3IID5Ft54HNJcEC4zi31BCPqi_687X3CSp7rXaRVY,5907
29
29
  voxcity/utils/visualization.py,sha256=FNBMN0V5IPuAdqvLHnqSGYqNS7jWesg0ZADEtsUtl0A,31925
30
30
  voxcity/utils/weather.py,sha256=P6s1y_EstBL1OGP_MR_6u3vr-t6Uawg8uDckJnoI7FI,21482
31
- voxcity-0.3.8.dist-info/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
32
- voxcity-0.3.8.dist-info/LICENSE,sha256=-hGliOFiwUrUSoZiB5WF90xXGqinKyqiDI2t6hrnam8,1087
33
- voxcity-0.3.8.dist-info/METADATA,sha256=schh7WkH29ejBg5XLv_dfiqrS5Gm6Tg8uLxNcULp634,25087
34
- voxcity-0.3.8.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
35
- voxcity-0.3.8.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
36
- voxcity-0.3.8.dist-info/RECORD,,
31
+ voxcity-0.3.9.dist-info/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
32
+ voxcity-0.3.9.dist-info/LICENSE,sha256=-hGliOFiwUrUSoZiB5WF90xXGqinKyqiDI2t6hrnam8,1087
33
+ voxcity-0.3.9.dist-info/METADATA,sha256=wbktfEaoqiafDk3sX8gZxM2m6-pYxXDkGI5daZbU-S4,25110
34
+ voxcity-0.3.9.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
35
+ voxcity-0.3.9.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
36
+ voxcity-0.3.9.dist-info/RECORD,,