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.
- {huff-1.5.5 → huff-1.5.7}/PKG-INFO +4 -5
- {huff-1.5.5 → huff-1.5.7}/README.md +3 -4
- {huff-1.5.5 → huff-1.5.7}/huff/gistools.py +210 -5
- {huff-1.5.5 → huff-1.5.7}/huff/models.py +28 -2
- huff-1.5.7/huff/osm.py +130 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/tests_huff.py +3 -4
- {huff-1.5.5 → huff-1.5.7}/huff.egg-info/PKG-INFO +4 -5
- {huff-1.5.5 → huff-1.5.7}/setup.py +1 -1
- huff-1.5.5/huff/osm.py +0 -267
- {huff-1.5.5 → huff-1.5.7}/MANIFEST.in +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/__init__.py +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/ors.py +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/__init__.py +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.cpg +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.dbf +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.prj +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.qmd +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.shp +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach.shx +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.cpg +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.dbf +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.prj +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.qmd +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.shp +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_new_supermarket.shx +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.cpg +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.dbf +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.prj +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.qmd +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.shp +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Haslach_supermarkets.shx +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff/tests/data/Wieland2015.xlsx +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff.egg-info/SOURCES.txt +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff.egg-info/dependency_links.txt +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff.egg-info/requires.txt +0 -0
- {huff-1.5.5 → huff-1.5.7}/huff.egg-info/top_level.txt +0 -0
- {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.
|
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.
|
22
|
-
-
|
23
|
-
-
|
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.
|
14
|
-
-
|
15
|
-
-
|
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.
|
8
|
-
# Last update: 2025-
|
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
|
-
|
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(
|
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.
|
8
|
-
# Last update: 2025-
|
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.
|
8
|
-
# Last update: 2025-
|
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.
|
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.
|
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.
|
22
|
-
-
|
23
|
-
-
|
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
|
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
|
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
|