voxcity 0.3.8__py3-none-any.whl → 0.3.10__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/file/geojson.py +306 -99
- voxcity/geo/network.py +452 -141
- voxcity/sim/solar.py +12 -7
- voxcity/sim/view.py +5 -3
- voxcity/voxcity.py +23 -20
- {voxcity-0.3.8.dist-info → voxcity-0.3.10.dist-info}/METADATA +5 -4
- {voxcity-0.3.8.dist-info → voxcity-0.3.10.dist-info}/RECORD +11 -11
- {voxcity-0.3.8.dist-info → voxcity-0.3.10.dist-info}/WHEEL +1 -1
- {voxcity-0.3.8.dist-info → voxcity-0.3.10.dist-info}/AUTHORS.rst +0 -0
- {voxcity-0.3.8.dist-info → voxcity-0.3.10.dist-info}/LICENSE +0 -0
- {voxcity-0.3.8.dist-info → voxcity-0.3.10.dist-info}/top_level.txt +0 -0
voxcity/geo/network.py
CHANGED
|
@@ -6,189 +6,500 @@ 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
|
|
|
12
|
-
def
|
|
18
|
+
def vectorized_edge_values(G, polygons_gdf, value_col='value'):
|
|
13
19
|
"""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
G : NetworkX Graph
|
|
19
|
-
Input graph with edges to analyze
|
|
20
|
-
gdf : GeoDataFrame
|
|
21
|
-
Grid containing polygons with values
|
|
22
|
-
value_col : str, default 'value'
|
|
23
|
-
Name of the column containing values in the grid
|
|
24
|
-
|
|
25
|
-
Returns:
|
|
26
|
-
--------
|
|
27
|
-
dict
|
|
28
|
-
Dictionary with edge identifiers (u,v,k) as keys and average values as values
|
|
20
|
+
Compute average polygon values along each edge by:
|
|
21
|
+
1) Building an Edge GeoDataFrame in linestring form
|
|
22
|
+
2) Using gpd.overlay or sjoin to get their intersection with polygons
|
|
23
|
+
3) Computing length-weighted average
|
|
29
24
|
"""
|
|
25
|
+
# 1) Build edge GeoDataFrame (EPSG:4326)
|
|
26
|
+
records = []
|
|
27
|
+
for i, (u, v, k, data) in enumerate(G.edges(keys=True, data=True)):
|
|
28
|
+
if 'geometry' in data:
|
|
29
|
+
edge_geom = data['geometry']
|
|
30
|
+
else:
|
|
31
|
+
start_node = G.nodes[u]
|
|
32
|
+
end_node = G.nodes[v]
|
|
33
|
+
edge_geom = LineString([(start_node['x'], start_node['y']),
|
|
34
|
+
(end_node['x'], end_node['y'])])
|
|
35
|
+
records.append({
|
|
36
|
+
'edge_id': i, # unique ID for grouping
|
|
37
|
+
'u': u,
|
|
38
|
+
'v': v,
|
|
39
|
+
'k': k,
|
|
40
|
+
'geometry': edge_geom
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
edges_gdf = gpd.GeoDataFrame(records, crs="EPSG:4326")
|
|
44
|
+
if polygons_gdf.crs != edges_gdf.crs:
|
|
45
|
+
polygons_gdf = polygons_gdf.to_crs(edges_gdf.crs)
|
|
46
|
+
|
|
47
|
+
# 2) Use a projected CRS for length calculations
|
|
48
|
+
edges_3857 = edges_gdf.to_crs(epsg=3857)
|
|
49
|
+
polys_3857 = polygons_gdf.to_crs(epsg=3857)
|
|
50
|
+
|
|
51
|
+
# 3) Intersection: lines vs polygons -> lines clipped to polygons
|
|
52
|
+
# gpd.overlay with how='intersection' can yield partial lines
|
|
53
|
+
intersected = gpd.overlay(edges_3857, polys_3857, how='intersection')
|
|
54
|
+
|
|
55
|
+
# Now each row is a geometry representing the intersection segment,
|
|
56
|
+
# with columns from edges + polygons.
|
|
57
|
+
# For lines, 'intersection' yields the line portion inside each polygon.
|
|
58
|
+
# We'll compute the length, then do a length-weighted average of value_col.
|
|
59
|
+
|
|
60
|
+
intersected['seg_length'] = intersected.geometry.length
|
|
61
|
+
# Weighted contribution = seg_length * polygon_value
|
|
62
|
+
intersected['weighted_val'] = intersected['seg_length'] * intersected[value_col]
|
|
63
|
+
|
|
64
|
+
# 4) Group by edge_id
|
|
65
|
+
grouped = intersected.groupby('edge_id')
|
|
66
|
+
results = grouped.apply(
|
|
67
|
+
lambda df: df['weighted_val'].sum() / df['seg_length'].sum()
|
|
68
|
+
if df['seg_length'].sum() > 0 else np.nan
|
|
69
|
+
)
|
|
70
|
+
# results is a Series with index=edge_id
|
|
71
|
+
|
|
72
|
+
# 5) Map results back to edges
|
|
30
73
|
edge_values = {}
|
|
74
|
+
for edge_id, val in results.items():
|
|
75
|
+
rec = edges_gdf.iloc[edge_id]
|
|
76
|
+
edge_values[(rec['u'], rec['v'], rec['k'])] = val
|
|
77
|
+
|
|
78
|
+
return edge_values
|
|
79
|
+
|
|
80
|
+
def get_network_values(
|
|
81
|
+
grid,
|
|
82
|
+
rectangle_vertices,
|
|
83
|
+
meshsize,
|
|
84
|
+
value_name='value',
|
|
85
|
+
**kwargs
|
|
86
|
+
):
|
|
87
|
+
defaults = {
|
|
88
|
+
'network_type': 'walk',
|
|
89
|
+
'vis_graph': True,
|
|
90
|
+
'colormap': 'viridis',
|
|
91
|
+
'vmin': None,
|
|
92
|
+
'vmax': None,
|
|
93
|
+
'edge_width': 1,
|
|
94
|
+
'fig_size': (15,15),
|
|
95
|
+
'zoom': 16,
|
|
96
|
+
'basemap_style': ctx.providers.CartoDB.Positron,
|
|
97
|
+
'save_path': None
|
|
98
|
+
}
|
|
99
|
+
settings = {**defaults, **kwargs}
|
|
100
|
+
|
|
101
|
+
# Build polygons GDF if needed
|
|
102
|
+
polygons_gdf = (grid if isinstance(grid, gpd.GeoDataFrame)
|
|
103
|
+
else grid_to_geodataframe(grid, rectangle_vertices, meshsize))
|
|
104
|
+
if polygons_gdf.crs is None:
|
|
105
|
+
polygons_gdf.set_crs(epsg=4326, inplace=True)
|
|
106
|
+
|
|
107
|
+
# BBox
|
|
108
|
+
north, south = rectangle_vertices[1][1], rectangle_vertices[0][1]
|
|
109
|
+
east, west = rectangle_vertices[2][0], rectangle_vertices[0][0]
|
|
110
|
+
bbox = (west, south, east, north)
|
|
111
|
+
|
|
112
|
+
# Download OSMnx network
|
|
113
|
+
G = ox.graph.graph_from_bbox(
|
|
114
|
+
bbox=bbox,
|
|
115
|
+
network_type=settings['network_type'],
|
|
116
|
+
simplify=True
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Compute edge values with the vectorized function
|
|
120
|
+
edge_values = vectorized_edge_values(G, polygons_gdf, value_col="value")
|
|
121
|
+
nx.set_edge_attributes(G, edge_values, name=value_name)
|
|
122
|
+
|
|
123
|
+
# Build edge GDF
|
|
124
|
+
edges_with_values = []
|
|
31
125
|
for u, v, k, data in G.edges(data=True, keys=True):
|
|
32
126
|
if 'geometry' in data:
|
|
33
|
-
|
|
127
|
+
geom = data['geometry']
|
|
34
128
|
else:
|
|
35
129
|
start_node = G.nodes[u]
|
|
36
130
|
end_node = G.nodes[v]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
131
|
+
geom = LineString([(start_node['x'], start_node['y']),
|
|
132
|
+
(end_node['x'], end_node['y'])])
|
|
133
|
+
|
|
134
|
+
val = data.get(value_name, np.nan)
|
|
135
|
+
edges_with_values.append({
|
|
136
|
+
'u': u, 'v': v, 'key': k,
|
|
137
|
+
'geometry': geom,
|
|
138
|
+
value_name: val
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
|
|
142
|
+
|
|
143
|
+
# Save
|
|
144
|
+
if settings['save_path']:
|
|
145
|
+
edge_gdf.to_file(settings['save_path'], driver="GPKG")
|
|
146
|
+
|
|
147
|
+
if settings['vis_graph']:
|
|
148
|
+
edge_gdf_web = edge_gdf.to_crs(epsg=3857)
|
|
149
|
+
fig, ax = plt.subplots(figsize=settings['fig_size'])
|
|
150
|
+
edge_gdf_web.plot(
|
|
151
|
+
column=value_name,
|
|
152
|
+
ax=ax,
|
|
153
|
+
cmap=settings['colormap'],
|
|
154
|
+
legend=True,
|
|
155
|
+
vmin=settings['vmin'],
|
|
156
|
+
vmax=settings['vmax'],
|
|
157
|
+
linewidth=settings['edge_width'],
|
|
158
|
+
legend_kwds={'label': value_name, 'shrink': 0.5}
|
|
159
|
+
)
|
|
160
|
+
ctx.add_basemap(ax, source=settings['basemap_style'], zoom=settings['zoom'])
|
|
161
|
+
ax.set_axis_off()
|
|
162
|
+
plt.show()
|
|
163
|
+
|
|
164
|
+
return G, edge_gdf
|
|
165
|
+
|
|
166
|
+
# -------------------------------------------------------------------
|
|
167
|
+
# Optionally import your DEM helper
|
|
168
|
+
# -------------------------------------------------------------------
|
|
169
|
+
from voxcity.geo.grid import grid_to_geodataframe
|
|
170
|
+
|
|
171
|
+
# -------------------------------------------------------------------
|
|
172
|
+
# 1) Functions for interpolation, parallelization, and slope
|
|
173
|
+
# -------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def interpolate_points_along_line(line, interval):
|
|
176
|
+
"""
|
|
177
|
+
Interpolate points along a single LineString at a given interval (in meters).
|
|
178
|
+
If the line is shorter than `interval`, only start/end points are returned.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
line : shapely.geometry.LineString
|
|
183
|
+
Edge geometry in EPSG:4326 (lon/lat).
|
|
184
|
+
interval : float
|
|
185
|
+
Distance in meters between interpolated points.
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
list of shapely.geometry.Point
|
|
190
|
+
Points in EPSG:4326 along the line.
|
|
191
|
+
"""
|
|
192
|
+
if line.is_empty:
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
# Transformers for metric distance calculations
|
|
196
|
+
project = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
|
|
197
|
+
project_rev = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True).transform
|
|
198
|
+
|
|
199
|
+
# Project line to Web Mercator
|
|
200
|
+
line_merc = shapely.ops.transform(project, line)
|
|
201
|
+
length_m = line_merc.length
|
|
202
|
+
if length_m == 0:
|
|
203
|
+
return [Point(line.coords[0])]
|
|
204
|
+
|
|
205
|
+
# If line is shorter than interval, just start & end
|
|
206
|
+
if length_m < interval:
|
|
207
|
+
return [Point(line.coords[0]), Point(line.coords[-1])]
|
|
208
|
+
|
|
209
|
+
# Otherwise, create distances
|
|
210
|
+
num_points = int(length_m // interval)
|
|
211
|
+
dists = [i * interval for i in range(num_points + 1)]
|
|
212
|
+
# Ensure end
|
|
213
|
+
if dists[-1] < length_m:
|
|
214
|
+
dists.append(length_m)
|
|
215
|
+
|
|
216
|
+
# Interpolate
|
|
217
|
+
points_merc = [line_merc.interpolate(d) for d in dists]
|
|
218
|
+
# Reproject back
|
|
219
|
+
return [shapely.ops.transform(project_rev, pt) for pt in points_merc]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def gather_interpolation_points(G, interval=10.0, n_jobs=1):
|
|
223
|
+
"""
|
|
224
|
+
Gather all interpolation points for each edge in the graph into a single GeoDataFrame.
|
|
225
|
+
Can be parallelized with `n_jobs`.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
G : networkx.MultiDiGraph
|
|
230
|
+
OSMnx graph with 'geometry' attributes or x,y coordinates in the nodes.
|
|
231
|
+
interval : float, default=10.0
|
|
232
|
+
Interpolation distance interval in meters.
|
|
233
|
+
n_jobs : int, default=1
|
|
234
|
+
Number of parallel jobs (1 => no parallelization).
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
gpd.GeoDataFrame
|
|
239
|
+
Columns: edge_id, index_in_edge, geometry (EPSG:4326).
|
|
240
|
+
"""
|
|
241
|
+
edges = list(G.edges(keys=True, data=True))
|
|
242
|
+
|
|
243
|
+
def process_edge(u, v, k, data, idx):
|
|
244
|
+
if 'geometry' in data:
|
|
245
|
+
line = data['geometry']
|
|
61
246
|
else:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
247
|
+
# If no geometry, build from node coords
|
|
248
|
+
start_node = G.nodes[u]
|
|
249
|
+
end_node = G.nodes[v]
|
|
250
|
+
line = LineString([(start_node['x'], start_node['y']),
|
|
251
|
+
(end_node['x'], end_node['y'])])
|
|
252
|
+
|
|
253
|
+
pts = interpolate_points_along_line(line, interval)
|
|
254
|
+
df = pd.DataFrame({
|
|
255
|
+
'edge_id': [idx]*len(pts),
|
|
256
|
+
'index_in_edge': np.arange(len(pts)),
|
|
257
|
+
'geometry': pts
|
|
258
|
+
})
|
|
259
|
+
return df
|
|
260
|
+
|
|
261
|
+
# Parallel interpolation
|
|
262
|
+
results = Parallel(n_jobs=n_jobs, backend='threading')(
|
|
263
|
+
delayed(process_edge)(u, v, k, data, i)
|
|
264
|
+
for i, (u, v, k, data) in enumerate(edges)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
all_points_df = pd.concat(results, ignore_index=True)
|
|
268
|
+
points_gdf = gpd.GeoDataFrame(all_points_df, geometry='geometry', crs="EPSG:4326")
|
|
269
|
+
return points_gdf
|
|
65
270
|
|
|
66
|
-
|
|
271
|
+
|
|
272
|
+
def fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value'):
|
|
67
273
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Parameters
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
274
|
+
Do a spatial join (nearest) in a projected CRS (EPSG:3857) to fetch DEM elevations.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
points_gdf_3857 : gpd.GeoDataFrame
|
|
279
|
+
Interpolation points in EPSG:3857.
|
|
280
|
+
dem_gdf_3857 : gpd.GeoDataFrame
|
|
281
|
+
DEM polygons in EPSG:3857, must have `elevation_col`.
|
|
282
|
+
elevation_col : str, default='value'
|
|
283
|
+
Column with elevation values in dem_gdf_3857.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
gpd.GeoDataFrame
|
|
288
|
+
A copy of points_gdf_3857 with new column 'elevation'.
|
|
289
|
+
"""
|
|
290
|
+
joined = gpd.sjoin_nearest(
|
|
291
|
+
points_gdf_3857,
|
|
292
|
+
dem_gdf_3857[[elevation_col, 'geometry']].copy(),
|
|
293
|
+
how='left',
|
|
294
|
+
distance_col='dist_to_poly'
|
|
295
|
+
)
|
|
296
|
+
joined.rename(columns={elevation_col: 'elevation'}, inplace=True)
|
|
297
|
+
return joined
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def compute_slope_for_group(df):
|
|
301
|
+
"""
|
|
302
|
+
Given a subset of points for a single edge, compute average slope between
|
|
303
|
+
consecutive points, using columns: geometry, elevation, index_in_edge.
|
|
304
|
+
|
|
305
|
+
Note: We assume df is already in EPSG:3857 for direct distance calculations.
|
|
306
|
+
"""
|
|
307
|
+
# Sort by position along the edge
|
|
308
|
+
df = df.sort_values("index_in_edge")
|
|
309
|
+
|
|
310
|
+
# Coordinates
|
|
311
|
+
xs = df.geometry.x.to_numpy()
|
|
312
|
+
ys = df.geometry.y.to_numpy()
|
|
313
|
+
elevs = df["elevation"].to_numpy()
|
|
314
|
+
|
|
315
|
+
# Differences
|
|
316
|
+
dx = np.diff(xs)
|
|
317
|
+
dy = np.diff(ys)
|
|
318
|
+
horizontal_dist = np.sqrt(dx**2 + dy**2)
|
|
319
|
+
elev_diff = np.diff(elevs)
|
|
320
|
+
|
|
321
|
+
# Slope in %
|
|
322
|
+
valid_mask = horizontal_dist > 0
|
|
323
|
+
slopes = (np.abs(elev_diff[valid_mask]) / horizontal_dist[valid_mask]) * 100
|
|
324
|
+
|
|
325
|
+
if len(slopes) == 0:
|
|
326
|
+
return np.nan
|
|
327
|
+
return slopes.mean()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def calculate_edge_slopes_from_join(joined_points_gdf, n_edges):
|
|
331
|
+
"""
|
|
332
|
+
Calculate average slopes for each edge by grouping joined points.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
joined_points_gdf : gpd.GeoDataFrame
|
|
337
|
+
Must have columns: edge_id, index_in_edge, elevation, geometry (EPSG:3857).
|
|
338
|
+
n_edges : int
|
|
339
|
+
Number of edges from the graph.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
dict
|
|
344
|
+
edge_id -> average slope (in %).
|
|
345
|
+
"""
|
|
346
|
+
# We'll group by edge_id, ignoring the group columns in apply (pandas >= 2.1).
|
|
347
|
+
# If your pandas version < 2.1, just do a column subset after groupby.
|
|
348
|
+
# E.g. .groupby("edge_id", group_keys=False)[["geometry","elevation","index_in_edge"]]...
|
|
349
|
+
grouped = joined_points_gdf.groupby("edge_id", group_keys=False)
|
|
350
|
+
results = grouped[["geometry", "elevation", "index_in_edge"]].apply(compute_slope_for_group)
|
|
351
|
+
|
|
352
|
+
# Convert series -> dict
|
|
353
|
+
slope_dict = results.to_dict()
|
|
354
|
+
|
|
355
|
+
# Fill any missing edge IDs with NaN
|
|
356
|
+
for i in range(n_edges):
|
|
357
|
+
if i not in slope_dict:
|
|
358
|
+
slope_dict[i] = np.nan
|
|
359
|
+
|
|
360
|
+
return slope_dict
|
|
361
|
+
|
|
362
|
+
# -------------------------------------------------------------------
|
|
363
|
+
# 2) Main function to analyze network slopes
|
|
364
|
+
# -------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def analyze_network_slopes(
|
|
367
|
+
dem_grid,
|
|
368
|
+
meshsize,
|
|
369
|
+
value_name='slope',
|
|
370
|
+
interval=10.0,
|
|
371
|
+
n_jobs=1,
|
|
372
|
+
**kwargs
|
|
373
|
+
):
|
|
374
|
+
"""
|
|
375
|
+
Analyze and visualize network slopes based on DEM data, using vectorized + parallel methods.
|
|
376
|
+
|
|
377
|
+
Parameters
|
|
378
|
+
----------
|
|
379
|
+
dem_grid : array-like
|
|
380
|
+
DEM grid data.
|
|
76
381
|
meshsize : float
|
|
77
|
-
|
|
78
|
-
value_name : str, default
|
|
79
|
-
|
|
382
|
+
Mesh grid size.
|
|
383
|
+
value_name : str, default='slope'
|
|
384
|
+
Column name for slopes assigned to each edge.
|
|
385
|
+
interval : float, default=10.0
|
|
386
|
+
Interpolation distance in meters.
|
|
387
|
+
n_jobs : int, default=1
|
|
388
|
+
Parallelization for edge interpolation (1 => sequential).
|
|
80
389
|
**kwargs : dict
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Matplotlib colormap name for visualization
|
|
88
|
-
- vmin : float, optional
|
|
89
|
-
Minimum value for color scaling
|
|
90
|
-
- vmax : float, optional
|
|
91
|
-
Maximum value for color scaling
|
|
92
|
-
- edge_width : float, default 1
|
|
93
|
-
Width of the edges in visualization
|
|
94
|
-
- fig_size : tuple, default (15,15)
|
|
95
|
-
Figure size for visualization
|
|
96
|
-
- zoom : int, default 16
|
|
97
|
-
Zoom level for the basemap
|
|
98
|
-
- basemap_style : ctx.providers, default CartoDB.Positron
|
|
99
|
-
Contextily basemap provider
|
|
100
|
-
- save_path : str, optional
|
|
101
|
-
Path to save the output GeoPackage
|
|
102
|
-
|
|
103
|
-
Returns:
|
|
104
|
-
--------
|
|
105
|
-
tuple : (NetworkX Graph, GeoDataFrame)
|
|
106
|
-
Returns the processed graph and edge GeoDataFrame
|
|
390
|
+
Additional parameters:
|
|
391
|
+
- rectangle_vertices : list of (x, y) in EPSG:4326
|
|
392
|
+
- network_type : str, default='walk'
|
|
393
|
+
- vis_graph : bool, default=True
|
|
394
|
+
- colormap, vmin, vmax, edge_width, fig_size, zoom, basemap_style, alpha
|
|
395
|
+
- output_directory, output_file_name
|
|
107
396
|
"""
|
|
108
|
-
# Set default values for optional arguments
|
|
109
397
|
defaults = {
|
|
398
|
+
'rectangle_vertices': None,
|
|
110
399
|
'network_type': 'walk',
|
|
111
400
|
'vis_graph': True,
|
|
112
401
|
'colormap': 'viridis',
|
|
113
402
|
'vmin': None,
|
|
114
403
|
'vmax': None,
|
|
115
404
|
'edge_width': 1,
|
|
116
|
-
'fig_size': (15,15),
|
|
405
|
+
'fig_size': (15, 15),
|
|
117
406
|
'zoom': 16,
|
|
118
407
|
'basemap_style': ctx.providers.CartoDB.Positron,
|
|
119
|
-
'
|
|
408
|
+
'output_directory': None,
|
|
409
|
+
'output_file_name': 'network_slopes',
|
|
410
|
+
'alpha': 1.0
|
|
120
411
|
}
|
|
121
|
-
|
|
122
|
-
# Update defaults with provided kwargs
|
|
123
|
-
settings = defaults.copy()
|
|
124
|
-
settings.update(kwargs)
|
|
412
|
+
settings = {**defaults, **kwargs}
|
|
125
413
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
414
|
+
# Validate bounding box
|
|
415
|
+
if settings['rectangle_vertices'] is None:
|
|
416
|
+
raise ValueError("Must supply 'rectangle_vertices' in kwargs.")
|
|
417
|
+
|
|
418
|
+
# 1) Build DEM GeoDataFrame in EPSG:4326
|
|
419
|
+
dem_gdf = grid_to_geodataframe(dem_grid, settings['rectangle_vertices'], meshsize)
|
|
420
|
+
if dem_gdf.crs is None:
|
|
421
|
+
dem_gdf.set_crs(epsg=4326, inplace=True)
|
|
422
|
+
|
|
423
|
+
# 2) Download bounding box from rectangle_vertices
|
|
424
|
+
north, south = settings['rectangle_vertices'][1][1], settings['rectangle_vertices'][0][1]
|
|
425
|
+
east, west = settings['rectangle_vertices'][2][0], settings['rectangle_vertices'][0][0]
|
|
131
426
|
bbox = (west, south, east, north)
|
|
427
|
+
|
|
428
|
+
G = ox.graph.graph_from_bbox(
|
|
429
|
+
bbox=bbox,
|
|
430
|
+
network_type=settings['network_type'],
|
|
431
|
+
simplify=True
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# 3) Interpolate points along edges (EPSG:4326)
|
|
435
|
+
points_gdf_4326 = gather_interpolation_points(G, interval=interval, n_jobs=n_jobs)
|
|
132
436
|
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
437
|
+
# 4) Reproject DEM + Points to EPSG:3857 for correct distance operations
|
|
438
|
+
dem_gdf_3857 = dem_gdf.to_crs(epsg=3857)
|
|
439
|
+
points_gdf_3857 = points_gdf_4326.to_crs(epsg=3857)
|
|
440
|
+
|
|
441
|
+
# 5) Perform spatial join to get elevations
|
|
442
|
+
joined_points_3857 = fetch_elevations_for_points(points_gdf_3857, dem_gdf_3857, elevation_col='value')
|
|
443
|
+
|
|
444
|
+
# 6) Compute slopes for each edge
|
|
445
|
+
n_edges = len(list(G.edges(keys=True)))
|
|
446
|
+
slope_dict = calculate_edge_slopes_from_join(joined_points_3857, n_edges)
|
|
447
|
+
|
|
448
|
+
# 7) Assign slopes back to G
|
|
449
|
+
edges = list(G.edges(keys=True, data=True))
|
|
450
|
+
edge_slopes = {}
|
|
451
|
+
for i, (u, v, k, data) in enumerate(edges):
|
|
452
|
+
edge_slopes[(u, v, k)] = slope_dict.get(i, np.nan)
|
|
453
|
+
nx.set_edge_attributes(G, edge_slopes, name=value_name)
|
|
454
|
+
|
|
455
|
+
# 8) Build an edge GeoDataFrame in EPSG:4326
|
|
143
456
|
edges_with_values = []
|
|
144
|
-
for u, v, k, data in
|
|
457
|
+
for (u, v, k, data), edge_id in zip(edges, range(len(edges))):
|
|
145
458
|
if 'geometry' in data:
|
|
146
|
-
|
|
459
|
+
geom = data['geometry']
|
|
147
460
|
else:
|
|
148
461
|
start_node = G.nodes[u]
|
|
149
462
|
end_node = G.nodes[v]
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
463
|
+
geom = LineString([(start_node['x'], start_node['y']),
|
|
464
|
+
(end_node['x'], end_node['y'])])
|
|
465
|
+
|
|
153
466
|
edges_with_values.append({
|
|
154
|
-
'geometry': edge_line,
|
|
155
|
-
value_name: data.get(value_name, np.nan),
|
|
156
467
|
'u': u,
|
|
157
468
|
'v': v,
|
|
158
|
-
'key': k
|
|
469
|
+
'key': k,
|
|
470
|
+
'geometry': geom,
|
|
471
|
+
value_name: slope_dict.get(edge_id, np.nan)
|
|
159
472
|
})
|
|
160
|
-
|
|
161
|
-
edge_gdf = gpd.GeoDataFrame(edges_with_values)
|
|
162
|
-
|
|
163
|
-
#
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
473
|
+
|
|
474
|
+
edge_gdf = gpd.GeoDataFrame(edges_with_values, crs="EPSG:4326")
|
|
475
|
+
|
|
476
|
+
# 9) Save output if requested
|
|
477
|
+
if settings['output_directory']:
|
|
478
|
+
os.makedirs(settings['output_directory'], exist_ok=True)
|
|
479
|
+
out_path = os.path.join(
|
|
480
|
+
settings['output_directory'],
|
|
481
|
+
f"{settings['output_file_name']}.gpkg"
|
|
482
|
+
)
|
|
483
|
+
edge_gdf.to_file(out_path, driver="GPKG")
|
|
484
|
+
|
|
485
|
+
# 10) Visualization
|
|
171
486
|
if settings['vis_graph']:
|
|
172
487
|
edge_gdf_web = edge_gdf.to_crs(epsg=3857)
|
|
173
|
-
|
|
174
488
|
fig, ax = plt.subplots(figsize=settings['fig_size'])
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
ctx.add_basemap(ax,
|
|
187
|
-
source=settings['basemap_style'],
|
|
188
|
-
zoom=settings['zoom'])
|
|
189
|
-
|
|
489
|
+
edge_gdf_web.plot(
|
|
490
|
+
column=value_name,
|
|
491
|
+
ax=ax,
|
|
492
|
+
cmap=settings['colormap'],
|
|
493
|
+
legend=True,
|
|
494
|
+
vmin=settings['vmin'],
|
|
495
|
+
vmax=settings['vmax'],
|
|
496
|
+
linewidth=settings['edge_width'],
|
|
497
|
+
alpha=settings['alpha'],
|
|
498
|
+
legend_kwds={'label': f"{value_name} (%)"}
|
|
499
|
+
)
|
|
500
|
+
ctx.add_basemap(ax, source=settings['basemap_style'], zoom=settings['zoom'])
|
|
190
501
|
ax.set_axis_off()
|
|
191
|
-
|
|
502
|
+
plt.title(f'Network {value_name} Analysis', pad=20)
|
|
192
503
|
plt.show()
|
|
193
|
-
|
|
504
|
+
|
|
194
505
|
return G, edge_gdf
|