huff 1.5.5__tar.gz → 1.5.7__tar.gz

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 (37) hide show
  1. {huff-1.5.5 → huff-1.5.7}/PKG-INFO +4 -5
  2. {huff-1.5.5 → huff-1.5.7}/README.md +3 -4
  3. {huff-1.5.5 → huff-1.5.7}/huff/gistools.py +210 -5
  4. {huff-1.5.5 → huff-1.5.7}/huff/models.py +28 -2
  5. huff-1.5.7/huff/osm.py +130 -0
  6. {huff-1.5.5 → huff-1.5.7}/huff/tests/tests_huff.py +3 -4
  7. {huff-1.5.5 → huff-1.5.7}/huff.egg-info/PKG-INFO +4 -5
  8. {huff-1.5.5 → huff-1.5.7}/setup.py +1 -1
  9. huff-1.5.5/huff/osm.py +0 -267
  10. {huff-1.5.5 → huff-1.5.7}/MANIFEST.in +0 -0
  11. {huff-1.5.5 → huff-1.5.7}/huff/__init__.py +0 -0
  12. {huff-1.5.5 → huff-1.5.7}/huff/ors.py +0 -0
  13. {huff-1.5.5 → huff-1.5.7}/huff/tests/__init__.py +0 -0
  14. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.cpg +0 -0
  15. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.dbf +0 -0
  16. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.prj +0 -0
  17. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.qmd +0 -0
  18. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.shp +0 -0
  19. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.shx +0 -0
  20. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.cpg +0 -0
  21. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.dbf +0 -0
  22. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.prj +0 -0
  23. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.qmd +0 -0
  24. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.shp +0 -0
  25. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.shx +0 -0
  26. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.cpg +0 -0
  27. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.dbf +0 -0
  28. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.prj +0 -0
  29. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.qmd +0 -0
  30. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.shp +0 -0
  31. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.shx +0 -0
  32. {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Wieland2015.xlsx +0 -0
  33. {huff-1.5.5 → huff-1.5.7}/huff.egg-info/SOURCES.txt +0 -0
  34. {huff-1.5.5 → huff-1.5.7}/huff.egg-info/dependency_links.txt +0 -0
  35. {huff-1.5.5 → huff-1.5.7}/huff.egg-info/requires.txt +0 -0
  36. {huff-1.5.5 → huff-1.5.7}/huff.egg-info/top_level.txt +0 -0
  37. {huff-1.5.5 → huff-1.5.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: huff
3
- Version: 1.5.5
3
+ Version: 1.5.7
4
4
  Summary: huff: Huff Model Market Area Analysis
5
5
  Author: Thomas Wieland
6
6
  Author-email: geowieland@googlemail.com
@@ -18,10 +18,9 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
18
18
  See the /tests directory for usage examples of most of the included functions.
19
19
 
20
20
 
21
- ## Updates v1.5.5
22
- - Bugfixes:
23
- - Removing i = j cases in InteractionMatrix.hansen()
24
- - InteractionMatrix.utility(): Check for utility column
21
+ ## Updates v1.5.7
22
+ - Extensions:
23
+ - InteractionMatrix.summary(), MCIModel.summary() and HuffModel.summary() now return metadata and model fit (if available)
25
24
 
26
25
 
27
26
  ## Features
@@ -10,10 +10,9 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
10
10
  See the /tests directory for usage examples of most of the included functions.
11
11
 
12
12
 
13
- ## Updates v1.5.5
14
- - Bugfixes:
15
- - Removing i = j cases in InteractionMatrix.hansen()
16
- - InteractionMatrix.utility(): Check for utility column
13
+ ## Updates v1.5.7
14
+ - Extensions:
15
+ - InteractionMatrix.summary(), MCIModel.summary() and HuffModel.summary() now return metadata and model fit (if available)
17
16
 
18
17
 
19
18
  ## Features
@@ -4,22 +4,30 @@
4
4
  # Author: Thomas Wieland
5
5
  # ORCID: 0000-0001-5168-9846
6
6
  # mail: geowieland@googlemail.com
7
- # Version: 1.4.1
8
- # Last update: 2025-06-16 17:44
7
+ # Version: 1.4.2
8
+ # Last update: 2025-07-31 18:31
9
9
  # Copyright (c) 2025 Thomas Wieland
10
10
  #-----------------------------------------------------------------------
11
11
 
12
12
 
13
+ import os
13
14
  import geopandas as gp
14
15
  import pandas as pd
15
16
  from pandas.api.types import is_numeric_dtype
16
17
  from math import pi, sin, cos, acos
18
+ from matplotlib.patches import Patch
19
+ import matplotlib.pyplot as plt
20
+ from shapely.geometry import LineString, box
21
+ import contextily as cx
22
+ from PIL import Image
23
+ from huff.osm import get_basemap
17
24
 
18
25
 
19
26
  def distance_matrix(
20
27
  sources: list,
21
28
  destinations: list,
22
29
  unit: str = "m",
30
+ lines_gdf: bool = False
23
31
  ):
24
32
 
25
33
  def euclidean_distance (
@@ -48,18 +56,36 @@ def distance_matrix(
48
56
 
49
57
  matrix = []
50
58
 
59
+ if lines_gdf:
60
+ line_data = []
61
+
51
62
  for source in sources:
63
+
52
64
  row = []
53
65
  for destination in destinations:
66
+
54
67
  dist = euclidean_distance(
55
68
  source,
56
69
  destination,
57
70
  unit
58
71
  )
59
72
  row.append(dist)
73
+
74
+ if lines_gdf:
75
+ line = LineString([source, destination])
76
+ line_data.append({
77
+ "source": source,
78
+ "destination": destination,
79
+ "distance": dist,
80
+ "geometry": line
81
+ })
82
+
60
83
  matrix.append(row)
61
84
 
62
- return matrix
85
+ if lines_gdf:
86
+ return line_data
87
+ else:
88
+ return matrix
63
89
 
64
90
 
65
91
  def buffers(
@@ -72,7 +98,13 @@ def buffers(
72
98
  output_crs: str = "EPSG:4326"
73
99
  ):
74
100
 
75
- all_buffers_gdf = gp.GeoDataFrame(columns=[unique_id_col, "segment", "geometry"])
101
+ all_buffers_gdf = gp.GeoDataFrame(
102
+ columns=[
103
+ unique_id_col,
104
+ "segment",
105
+ "geometry"
106
+ ]
107
+ )
76
108
 
77
109
  for idx, row in point_gdf.iterrows():
78
110
 
@@ -115,7 +147,9 @@ def buffers(
115
147
  all_buffers_gdf = all_buffers_gdf.to_crs(output_crs)
116
148
 
117
149
  if save_output:
150
+
118
151
  all_buffers_gdf.to_file(output_filepath)
152
+
119
153
  print ("Saved as", output_filepath)
120
154
 
121
155
  return all_buffers_gdf
@@ -218,4 +252,175 @@ def point_spatial_join(
218
252
  return [
219
253
  shp_points_gdf_join,
220
254
  spatial_join_stat
221
- ]
255
+ ]
256
+
257
+ def map_with_basemap(
258
+ layers: list,
259
+ osm_basemap: bool = True,
260
+ zoom: int = 15,
261
+ figsize=(10, 10),
262
+ bounds_factor = [0.9999, 0.9999, 1.0001, 1.0001],
263
+ styles: dict = {},
264
+ save_output: bool = True,
265
+ output_filepath: str = "osm_map_with_basemap.png",
266
+ output_dpi=300,
267
+ legend: bool = True,
268
+ show_plot: bool = True,
269
+ verbose: bool = False
270
+ ):
271
+
272
+ if not isinstance(layers, list):
273
+ raise ValueError("Param 'layers' must be a list")
274
+
275
+ if not layers:
276
+ raise ValueError("List layers is empty")
277
+
278
+ if verbose:
279
+ print("Combining layers ...", end = " ")
280
+
281
+ layers_combined = gp.GeoDataFrame(
282
+ pd.concat(
283
+ layers,
284
+ ignore_index=True
285
+ ),
286
+ crs=layers[0].crs
287
+ )
288
+
289
+ layers_combined_wgs84 = layers_combined.to_crs(epsg=4326)
290
+
291
+ if verbose:
292
+ print("OK")
293
+ print("Retrieving total bounds ...", end = " ")
294
+
295
+ bounds = layers_combined_wgs84.total_bounds
296
+
297
+ sw_lon, sw_lat, ne_lon, ne_lat = bounds[0]*bounds_factor[0], bounds[1]*bounds_factor[1], bounds[2]*bounds_factor[2], bounds[3]*bounds_factor[3]
298
+
299
+ if verbose:
300
+ print("OK")
301
+
302
+ if osm_basemap:
303
+
304
+ if verbose:
305
+ print("Retrieving OSM basemap ...", end = " ")
306
+
307
+ get_basemap(sw_lat, sw_lon, ne_lat, ne_lon, zoom=zoom)
308
+
309
+ fig, ax = plt.subplots(figsize=figsize)
310
+
311
+ if osm_basemap:
312
+
313
+ img = Image.open("osm_map.png")
314
+ extent_img = [sw_lon, ne_lon, sw_lat, ne_lat]
315
+ ax.imshow(img, extent=extent_img, origin="upper")
316
+
317
+ if verbose:
318
+ print("OK")
319
+
320
+ if verbose:
321
+ print("Inserting layers and plotting map ...", end = " ")
322
+
323
+ i = 0
324
+ legend_handles = []
325
+
326
+ for layer in layers:
327
+
328
+ layer_3857 = layer.to_crs(epsg=3857)
329
+
330
+ if styles != {}:
331
+
332
+ layer_style = styles[i]
333
+ layer_color = layer_style["color"]
334
+ layer_alpha = layer_style["alpha"]
335
+ layer_name = layer_style["name"]
336
+
337
+ if isinstance(layer_color, str):
338
+ layer_3857.plot(
339
+ ax=ax,
340
+ color=layer_color,
341
+ alpha=layer_alpha,
342
+ label=layer_name
343
+ )
344
+ if legend:
345
+ patch = Patch(
346
+ facecolor=layer_color,
347
+ alpha=layer_alpha,
348
+ label=layer_name
349
+ )
350
+ legend_handles.append(patch)
351
+
352
+ elif isinstance(layer_color, dict):
353
+ color_key = list(layer_color.keys())[0]
354
+ color_mapping = layer_color[color_key]
355
+
356
+ if color_key not in layer_3857.columns:
357
+ raise KeyError("Column " + color_key + " not in layer.")
358
+
359
+ for value, color in color_mapping.items():
360
+
361
+ subset = layer_3857[layer_3857[color_key].astype(str) == str(value)]
362
+
363
+ if not subset.empty:
364
+
365
+ subset.plot(
366
+ ax=ax,
367
+ color=color,
368
+ alpha=layer_alpha,
369
+ label=str(value)
370
+ )
371
+
372
+ if legend:
373
+ patch = Patch(facecolor=color, alpha=layer_alpha, label=str(value))
374
+ legend_handles.append(patch)
375
+
376
+ else:
377
+
378
+ layer_3857.plot(ax=ax, alpha=0.6, label=f"Layer {i+1}")
379
+
380
+ if legend:
381
+
382
+ patch = Patch(
383
+ facecolor="gray",
384
+ alpha=0.6,
385
+ label=f"Layer {i+1}"
386
+ )
387
+
388
+ legend_handles.append(patch)
389
+
390
+ i += 1
391
+
392
+ bbox = box(sw_lon, sw_lat, ne_lon, ne_lat)
393
+ extent_geom = gp.GeoSeries([bbox], crs=4326).to_crs(epsg=3857).total_bounds
394
+ ax.set_xlim(extent_geom[0], extent_geom[2])
395
+ ax.set_ylim(extent_geom[1], extent_geom[3])
396
+
397
+ if osm_basemap:
398
+ cx.add_basemap(
399
+ ax,
400
+ source=cx.providers.OpenStreetMap.Mapnik,
401
+ zoom=zoom
402
+ )
403
+
404
+ plt.axis('off')
405
+
406
+ if legend and legend_handles:
407
+ ax.legend(handles=legend_handles, loc='lower right', fontsize='small', frameon=True)
408
+
409
+ if verbose:
410
+ print("OK")
411
+
412
+ if show_plot:
413
+ plt.show()
414
+
415
+ if save_output:
416
+ plt.savefig(
417
+ output_filepath,
418
+ dpi=output_dpi,
419
+ bbox_inches="tight"
420
+ )
421
+ plt.close()
422
+
423
+ if os.path.exists("osm_map.png"):
424
+ os.remove("osm_map.png")
425
+
426
+ return fig
@@ -4,8 +4,8 @@
4
4
  # Author: Thomas Wieland
5
5
  # ORCID: 0000-0001-5168-9846
6
6
  # mail: geowieland@googlemail.com
7
- # Version: 1.5.5
8
- # Last update: 2025-07-26 13:42
7
+ # Version: 1.5.6
8
+ # Last update: 2025-08-01 14:00
9
9
  # Copyright (c) 2025 Thomas Wieland
10
10
  #-----------------------------------------------------------------------
11
11
 
@@ -590,6 +590,12 @@ class InteractionMatrix:
590
590
  if interaction_matrix_metadata["fit"]["function"] == "huff_ml_fit":
591
591
  print("Fit method " + interaction_matrix_metadata["fit"]["method"] + " (Converged: " + str(interaction_matrix_metadata["fit"]["minimize_success"]) + ")")
592
592
 
593
+ return [
594
+ customer_origins_metadata,
595
+ supply_locations_metadata,
596
+ interaction_matrix_metadata
597
+ ]
598
+
593
599
  def transport_costs(
594
600
  self,
595
601
  network: bool = True,
@@ -1642,6 +1648,8 @@ class HuffModel:
1642
1648
 
1643
1649
  print("----------------------------------")
1644
1650
 
1651
+ huff_modelfit = None
1652
+
1645
1653
  if interaction_matrix_metadata != {} and "fit" in interaction_matrix_metadata and interaction_matrix_metadata["fit"]["function"] is not None:
1646
1654
  print("Parameter estimation")
1647
1655
  print("Fit function " + interaction_matrix_metadata["fit"]["function"])
@@ -1682,6 +1690,13 @@ class HuffModel:
1682
1690
 
1683
1691
  print("----------------------------------")
1684
1692
 
1693
+ return [
1694
+ customer_origins_metadata,
1695
+ supply_locations_metadata,
1696
+ interaction_matrix_metadata,
1697
+ huff_modelfit
1698
+ ]
1699
+
1685
1700
  def mci_fit(
1686
1701
  self,
1687
1702
  cols: list = ["A_j", "t_ij"],
@@ -2341,6 +2356,7 @@ class MCIModel:
2341
2356
 
2342
2357
  customer_origins_metadata = interaction_matrix.get_customer_origins().get_metadata()
2343
2358
  supply_locations_metadata = interaction_matrix.get_supply_locations().get_metadata()
2359
+ interaction_matrix_metadata = interaction_matrix.get_metadata()
2344
2360
 
2345
2361
  print("Multiplicative Competitive Interaction Model")
2346
2362
  print("--------------------------------------------")
@@ -2371,7 +2387,10 @@ class MCIModel:
2371
2387
 
2372
2388
  print("--------------------------------------------")
2373
2389
 
2390
+ mci_modelfit = None
2391
+
2374
2392
  mci_modelfit = self.modelfit()
2393
+
2375
2394
  if mci_modelfit is not None:
2376
2395
 
2377
2396
  print ("Goodness-of-fit for probabilities")
@@ -2402,6 +2421,13 @@ class MCIModel:
2402
2421
  print(APE_df.to_string(index=False))
2403
2422
 
2404
2423
  print("--------------------------------------------")
2424
+
2425
+ return [
2426
+ customer_origins_metadata,
2427
+ supply_locations_metadata,
2428
+ interaction_matrix_metadata,
2429
+ mci_modelfit
2430
+ ]
2405
2431
 
2406
2432
  def utility(
2407
2433
  self,
huff-1.5.7/huff/osm.py ADDED
@@ -0,0 +1,130 @@
1
+ #-----------------------------------------------------------------------
2
+ # Name: osm (huff package)
3
+ # Purpose: Helper functions for OpenStreetMap API
4
+ # Author: Thomas Wieland
5
+ # ORCID: 0000-0001-5168-9846
6
+ # mail: geowieland@googlemail.com
7
+ # Version: 1.4.2
8
+ # Last update: 2025-07-31 18:24
9
+ # Copyright (c) 2025 Thomas Wieland
10
+ #-----------------------------------------------------------------------
11
+
12
+
13
+ import math
14
+ import requests
15
+ import tempfile
16
+ import time
17
+ from PIL import Image
18
+
19
+
20
+ class Client:
21
+
22
+ def __init__(
23
+ self,
24
+ server = "http://a.tile.openstreetmap.org/",
25
+ headers = {
26
+ 'User-Agent': 'huff.osm/1.0.0 (your_name@your_email_provider.com)'
27
+ }
28
+ ):
29
+
30
+ self.server = server
31
+ self.headers = headers
32
+
33
+ def download_tile(
34
+ self,
35
+ zoom,
36
+ x,
37
+ y,
38
+ timeout = 10
39
+ ):
40
+
41
+ osm_url = self.server + f"{zoom}/{x}/{y}.png"
42
+
43
+ response = requests.get(
44
+ osm_url,
45
+ headers = self.headers,
46
+ timeout = timeout
47
+ )
48
+
49
+ if response.status_code == 200:
50
+
51
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp_file:
52
+ tmp_file.write(response.content)
53
+ tmp_file_path = tmp_file.name
54
+ return Image.open(tmp_file_path)
55
+
56
+ else:
57
+
58
+ print(f"Error while accessing OSM server. Status code: {response.status_code} - {response.reason}")
59
+
60
+ return None
61
+
62
+
63
+ def get_basemap(
64
+ sw_lat,
65
+ sw_lon,
66
+ ne_lat,
67
+ ne_lon,
68
+ zoom = 15
69
+ ):
70
+
71
+ def lat_lon_to_tile(
72
+ lat,
73
+ lon,
74
+ zoom
75
+ # https://wiki.openstreetmap.org/wiki/Zoom_levels
76
+ ):
77
+
78
+ n = 2 ** zoom
79
+ x = int(n * ((lon + 180) / 360))
80
+ y = int(n * (1 - (math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat))) / math.pi)) / 2)
81
+ return x, y
82
+
83
+ def stitch_tiles(
84
+ zoom,
85
+ sw_lat,
86
+ sw_lon,
87
+ ne_lat,
88
+ ne_lon,
89
+ delay = 0.1
90
+ ):
91
+
92
+ osm_client = Client(
93
+ server = "http://a.tile.openstreetmap.org/"
94
+ )
95
+
96
+ sw_x_tile, sw_y_tile = lat_lon_to_tile(sw_lat, sw_lon, zoom)
97
+ ne_x_tile, ne_y_tile = lat_lon_to_tile(ne_lat, ne_lon, zoom)
98
+
99
+ tile_size = 256
100
+ width = (ne_x_tile - sw_x_tile + 1) * tile_size
101
+ height = (sw_y_tile - ne_y_tile + 1) * tile_size
102
+
103
+ stitched_image = Image.new('RGB', (width, height))
104
+
105
+ for x in range(sw_x_tile, ne_x_tile + 1):
106
+ for y in range(ne_y_tile, sw_y_tile + 1):
107
+ tile = osm_client.download_tile(
108
+ zoom = zoom,
109
+ x = x,
110
+ y = y
111
+ )
112
+ if tile:
113
+
114
+ stitched_image.paste(tile, ((x - sw_x_tile) * tile_size, (sw_y_tile - y) * tile_size))
115
+ else:
116
+ print(f"Error while retrieving tile {x}, {y}.")
117
+
118
+ time.sleep(delay)
119
+
120
+ return stitched_image
121
+
122
+ stitched_image = stitch_tiles(zoom, sw_lat, sw_lon, ne_lat, ne_lon)
123
+
124
+ if stitched_image:
125
+
126
+ stitched_image_path = "osm_map.png"
127
+ stitched_image.save(stitched_image_path)
128
+
129
+ else:
130
+ print("Error while building stitched images")
@@ -4,15 +4,14 @@
4
4
  # Author: Thomas Wieland
5
5
  # ORCID: 0000-0001-5168-9846
6
6
  # mail: geowieland@googlemail.com
7
- # Version: 1.5.4
8
- # Last update: 2025-07-18 18:06
7
+ # Version: 1.5.7
8
+ # Last update: 2025-08-01 14:01
9
9
  # Copyright (c) 2025 Thomas Wieland
10
10
  #-----------------------------------------------------------------------
11
11
 
12
12
  from huff.models import create_interaction_matrix, get_isochrones, load_geodata, load_interaction_matrix, load_marketareas, market_shares, modelfit
13
13
  from huff.models import HuffModel
14
- from huff.osm import map_with_basemap
15
- from huff.gistools import buffers, point_spatial_join
14
+ from huff.gistools import buffers, point_spatial_join, map_with_basemap
16
15
 
17
16
 
18
17
  # Dealing with customer origins (statistical districts):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: huff
3
- Version: 1.5.5
3
+ Version: 1.5.7
4
4
  Summary: huff: Huff Model Market Area Analysis
5
5
  Author: Thomas Wieland
6
6
  Author-email: geowieland@googlemail.com
@@ -18,10 +18,9 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
18
18
  See the /tests directory for usage examples of most of the included functions.
19
19
 
20
20
 
21
- ## Updates v1.5.5
22
- - Bugfixes:
23
- - Removing i = j cases in InteractionMatrix.hansen()
24
- - InteractionMatrix.utility(): Check for utility column
21
+ ## Updates v1.5.7
22
+ - Extensions:
23
+ - InteractionMatrix.summary(), MCIModel.summary() and HuffModel.summary() now return metadata and model fit (if available)
25
24
 
26
25
 
27
26
  ## Features
@@ -7,7 +7,7 @@ def read_README():
7
7
 
8
8
  setup(
9
9
  name='huff',
10
- version='1.5.5',
10
+ version='1.5.7',
11
11
  description='huff: Huff Model Market Area Analysis',
12
12
  packages=find_packages(include=["huff", "huff.tests"]),
13
13
  include_package_data=True,
huff-1.5.5/huff/osm.py DELETED
@@ -1,267 +0,0 @@
1
- #-----------------------------------------------------------------------
2
- # Name: osm (huff package)
3
- # Purpose: Helper functions for OpenStreetMap API
4
- # Author: Thomas Wieland
5
- # ORCID: 0000-0001-5168-9846
6
- # mail: geowieland@googlemail.com
7
- # Version: 1.4.1
8
- # Last update: 2025-06-16 17:44
9
- # Copyright (c) 2025 Thomas Wieland
10
- #-----------------------------------------------------------------------
11
-
12
-
13
- import pandas as pd
14
- import geopandas as gpd
15
- import math
16
- import requests
17
- import tempfile
18
- import time
19
- import os
20
- from PIL import Image
21
- import matplotlib.pyplot as plt
22
- from matplotlib.patches import Patch
23
- import contextily as cx
24
- from shapely.geometry import box
25
-
26
-
27
- class Client:
28
-
29
- def __init__(
30
- self,
31
- server = "http://a.tile.openstreetmap.org/",
32
- headers = {
33
- 'User-Agent': 'huff.osm/1.0.0 (your_name@your_email_provider.com)'
34
- }
35
- ):
36
-
37
- self.server = server
38
- self.headers = headers
39
-
40
- def download_tile(
41
- self,
42
- zoom,
43
- x,
44
- y,
45
- timeout = 10
46
- ):
47
-
48
- osm_url = self.server + f"{zoom}/{x}/{y}.png"
49
-
50
- response = requests.get(
51
- osm_url,
52
- headers = self.headers,
53
- timeout = timeout
54
- )
55
-
56
- if response.status_code == 200:
57
-
58
- with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp_file:
59
- tmp_file.write(response.content)
60
- tmp_file_path = tmp_file.name
61
- return Image.open(tmp_file_path)
62
-
63
- else:
64
-
65
- print(f"Error while accessing OSM server. Status code: {response.status_code} - {response.reason}")
66
-
67
- return None
68
-
69
-
70
- def get_basemap(
71
- sw_lat,
72
- sw_lon,
73
- ne_lat,
74
- ne_lon,
75
- zoom = 15
76
- ):
77
-
78
- def lat_lon_to_tile(
79
- lat,
80
- lon,
81
- zoom
82
- ):
83
-
84
- n = 2 ** zoom
85
- x = int(n * ((lon + 180) / 360))
86
- y = int(n * (1 - (math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat))) / math.pi)) / 2)
87
- return x, y
88
-
89
- def stitch_tiles(
90
- zoom,
91
- sw_lat,
92
- sw_lon,
93
- ne_lat,
94
- ne_lon,
95
- delay = 0.1
96
- ):
97
-
98
- osm_client = Client(
99
- server = "http://a.tile.openstreetmap.org/"
100
- )
101
-
102
- sw_x_tile, sw_y_tile = lat_lon_to_tile(sw_lat, sw_lon, zoom)
103
- ne_x_tile, ne_y_tile = lat_lon_to_tile(ne_lat, ne_lon, zoom)
104
-
105
- tile_size = 256
106
- width = (ne_x_tile - sw_x_tile + 1) * tile_size
107
- height = (sw_y_tile - ne_y_tile + 1) * tile_size
108
-
109
- stitched_image = Image.new('RGB', (width, height))
110
-
111
- for x in range(sw_x_tile, ne_x_tile + 1):
112
- for y in range(ne_y_tile, sw_y_tile + 1):
113
- tile = osm_client.download_tile(
114
- zoom = zoom,
115
- x = x,
116
- y = y
117
- )
118
- if tile:
119
-
120
- stitched_image.paste(tile, ((x - sw_x_tile) * tile_size, (sw_y_tile - y) * tile_size))
121
- else:
122
- print(f"Error while retrieving tile {x}, {y}.")
123
-
124
- time.sleep(delay)
125
-
126
- return stitched_image
127
-
128
- stitched_image = stitch_tiles(zoom, sw_lat, sw_lon, ne_lat, ne_lon)
129
-
130
- if stitched_image:
131
-
132
- stitched_image_path = "osm_map.png"
133
- stitched_image.save(stitched_image_path)
134
-
135
- else:
136
- print("Error while building stitched images")
137
-
138
-
139
- def map_with_basemap(
140
- layers: list,
141
- osm_basemap: bool = True,
142
- zoom: int = 15,
143
- styles: dict = {},
144
- save_output: bool = True,
145
- output_filepath: str = "osm_map_with_basemap.png",
146
- output_dpi=300,
147
- legend: bool = True
148
- ):
149
- if not layers:
150
- raise ValueError("List layers is empty")
151
-
152
- combined = gpd.GeoDataFrame(
153
- pd.concat(layers, ignore_index=True),
154
- crs=layers[0].crs
155
- )
156
-
157
- combined_wgs84 = combined.to_crs(epsg=4326)
158
- bounds = combined_wgs84.total_bounds
159
-
160
- sw_lon, sw_lat, ne_lon, ne_lat = bounds[0]*0.9999, bounds[1]*0.9999, bounds[2]*1.0001, bounds[3]*1.0001
161
-
162
- if osm_basemap:
163
- get_basemap(sw_lat, sw_lon, ne_lat, ne_lon, zoom=zoom)
164
-
165
- fig, ax = plt.subplots(figsize=(10, 10))
166
-
167
- if osm_basemap:
168
- img = Image.open("osm_map.png")
169
- extent_img = [sw_lon, ne_lon, sw_lat, ne_lat]
170
- ax.imshow(img, extent=extent_img, origin="upper")
171
-
172
- i = 0
173
- legend_handles = []
174
-
175
- for layer in layers:
176
- layer_3857 = layer.to_crs(epsg=3857)
177
-
178
- if styles != {}:
179
- layer_style = styles[i]
180
- layer_color = layer_style["color"]
181
- layer_alpha = layer_style["alpha"]
182
- layer_name = layer_style["name"]
183
-
184
- if isinstance(layer_color, str):
185
- layer_3857.plot(
186
- ax=ax,
187
- color=layer_color,
188
- alpha=layer_alpha,
189
- label=layer_name
190
- )
191
- if legend:
192
- patch = Patch(
193
- facecolor=layer_color,
194
- alpha=layer_alpha,
195
- label=layer_name
196
- )
197
- legend_handles.append(patch)
198
-
199
- elif isinstance(layer_color, dict):
200
- color_key = list(layer_color.keys())[0]
201
- color_mapping = layer_color[color_key]
202
-
203
- if color_key not in layer_3857.columns:
204
- raise KeyError("Column " + color_key + " not in layer.")
205
-
206
- for value, color in color_mapping.items():
207
-
208
- subset = layer_3857[layer_3857[color_key].astype(str) == str(value)]
209
-
210
- if not subset.empty:
211
-
212
- subset.plot(
213
- ax=ax,
214
- color=color,
215
- alpha=layer_alpha,
216
- label=str(value)
217
- )
218
-
219
- if legend:
220
- patch = Patch(facecolor=color, alpha=layer_alpha, label=str(value))
221
- legend_handles.append(patch)
222
-
223
- else:
224
-
225
- layer_3857.plot(ax=ax, alpha=0.6, label=f"Layer {i+1}")
226
-
227
- if legend:
228
-
229
- patch = Patch(
230
- facecolor="gray",
231
- alpha=0.6,
232
- label=f"Layer {i+1}"
233
- )
234
-
235
- legend_handles.append(patch)
236
-
237
- i += 1
238
-
239
- bbox = box(sw_lon, sw_lat, ne_lon, ne_lat)
240
- extent_geom = gpd.GeoSeries([bbox], crs=4326).to_crs(epsg=3857).total_bounds
241
- ax.set_xlim(extent_geom[0], extent_geom[2])
242
- ax.set_ylim(extent_geom[1], extent_geom[3])
243
-
244
- if osm_basemap:
245
- cx.add_basemap(
246
- ax,
247
- source=cx.providers.OpenStreetMap.Mapnik,
248
- zoom=zoom
249
- )
250
-
251
- plt.axis('off')
252
-
253
- if legend and legend_handles:
254
- ax.legend(handles=legend_handles, loc='lower right', fontsize='small', frameon=True)
255
-
256
- plt.show()
257
-
258
- if save_output:
259
- plt.savefig(
260
- output_filepath,
261
- dpi=output_dpi,
262
- bbox_inches="tight"
263
- )
264
- plt.close()
265
-
266
- if os.path.exists("osm_map.png"):
267
- os.remove("osm_map.png")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes