traderoutes 0.0.3__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.
- traderoutes-0.0.3/LICENSE +21 -0
- traderoutes-0.0.3/PKG-INFO +166 -0
- traderoutes-0.0.3/README.md +138 -0
- traderoutes-0.0.3/pyproject.toml +41 -0
- traderoutes-0.0.3/setup.cfg +4 -0
- traderoutes-0.0.3/src/traderoutes/__init__.py +4 -0
- traderoutes-0.0.3/src/traderoutes/assets/bordersgraph.pkl +0 -0
- traderoutes-0.0.3/src/traderoutes/assets/fullgraph.pkl +4 -0
- traderoutes-0.0.3/src/traderoutes/borders.py +112 -0
- traderoutes-0.0.3/src/traderoutes/routes.py +539 -0
- traderoutes-0.0.3/src/traderoutes.egg-info/PKG-INFO +166 -0
- traderoutes-0.0.3/src/traderoutes.egg-info/SOURCES.txt +15 -0
- traderoutes-0.0.3/src/traderoutes.egg-info/dependency_links.txt +1 -0
- traderoutes-0.0.3/src/traderoutes.egg-info/requires.txt +4 -0
- traderoutes-0.0.3/src/traderoutes.egg-info/top_level.txt +1 -0
- traderoutes-0.0.3/tests/test_borders.py +38 -0
- traderoutes-0.0.3/tests/test_routes.py +80 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mario Milone
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: traderoutes
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Compute weighted international trade routes on a worldwide network.
|
|
5
|
+
Author-email: Mario Milone <milonemario@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/milonemario/traderoutes
|
|
8
|
+
Project-URL: Repository, https://github.com/milonemario/traderoutes
|
|
9
|
+
Project-URL: Issues, https://github.com/milonemario/traderoutes/issues
|
|
10
|
+
Keywords: trade routes,transportation network,graph,geography
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=2.4.0
|
|
24
|
+
Requires-Dist: igraph>=0.11.8
|
|
25
|
+
Requires-Dist: simplekml>=1.3.6
|
|
26
|
+
Requires-Dist: geopy>=2.4.1
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# traderoutes for Python
|
|
30
|
+
|
|
31
|
+
`traderoutes` provides country-border queries and weighted shortest-path trade
|
|
32
|
+
routes on a worldwide road, port, and shipping network.
|
|
33
|
+
|
|
34
|
+
The package bundles the precomputed graph assets required at runtime. Users do
|
|
35
|
+
not need the graph-generation code or a container to install and use
|
|
36
|
+
the published package.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install traderoutes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For development from this repository:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The package requires Python 3.11 or newer. Runtime dependencies are declared
|
|
51
|
+
in [`pyproject.toml`](pyproject.toml).
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import traderoutes as tr
|
|
57
|
+
|
|
58
|
+
tr.are_neighbors("FRA", "ITA") # True
|
|
59
|
+
tr.get_neighbors("FRA") # neighboring ISO3 codes
|
|
60
|
+
|
|
61
|
+
origin = tr.get_main_city("FRA")
|
|
62
|
+
destination = tr.get_main_city("NLD")
|
|
63
|
+
path = tr.get_path(origin, destination, road_cost=7, port_cost=1e4)
|
|
64
|
+
|
|
65
|
+
tr.get_pattern(path) # e.g. "crc"
|
|
66
|
+
tr.road_distance(path) # road distance in km
|
|
67
|
+
tr.sea_distance(path) # sea distance in km
|
|
68
|
+
tr.transits_i(path)
|
|
69
|
+
tr.transits_j(path)
|
|
70
|
+
tr.to_kml(path, "fra_nld.kml")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Main API
|
|
74
|
+
|
|
75
|
+
| Function | Description |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `are_neighbors(c1, c2)` | Whether two countries share a land border |
|
|
78
|
+
| `get_neighbors(c)` | Neighboring ISO3 country codes |
|
|
79
|
+
| `get_second_neighbors(c)` | Countries two border hops away |
|
|
80
|
+
| `get_common_neighbors(c1, c2)` | Countries neighboring both inputs |
|
|
81
|
+
| `countries()` | Countries represented in the transportation graph |
|
|
82
|
+
| `get_main_city(c)` | Main city vertex for a country |
|
|
83
|
+
| `get_great_circle(c1, c2)` | Great-circle distance in km |
|
|
84
|
+
| `get_path(v1, v2, ...)` | Weighted shortest path |
|
|
85
|
+
| `get_pattern(path)` | Compact path vertex-type pattern |
|
|
86
|
+
| `get_countries(path, ...)` | Country segments and distances |
|
|
87
|
+
| `road_distance(path)` | Road distance in km |
|
|
88
|
+
| `sea_distance(path)` | Sea distance in km |
|
|
89
|
+
| `transits_i(path, ...)` | Origin-side transit countries |
|
|
90
|
+
| `transits_j(path, ...)` | Destination-side transit countries |
|
|
91
|
+
| `to_kml(path, file)` | Export a path to KML |
|
|
92
|
+
|
|
93
|
+
## Podman targets
|
|
94
|
+
|
|
95
|
+
The [`Dockerfile`](Dockerfile) provides reproducible development, testing, and
|
|
96
|
+
build environments. Build from this directory after retrieving Git LFS files:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git lfs pull
|
|
100
|
+
podman build --target build-env -t traderoutes-python-build-env .
|
|
101
|
+
podman build --target test -t traderoutes-python-test .
|
|
102
|
+
podman build --target build -t traderoutes-python-build .
|
|
103
|
+
podman build --target wheel-test -t traderoutes-python-wheel-test .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The stages have different purposes:
|
|
107
|
+
|
|
108
|
+
- `build-env` provides only the build toolchain. Mount the source and output
|
|
109
|
+
directories to write artifacts directly to the host. It can also be used as
|
|
110
|
+
an interactive development environment by mounting the source tree.
|
|
111
|
+
- `test` installs the package from the source tree and runs the unit tests.
|
|
112
|
+
- `build` creates the wheel and source distribution under `/dist`.
|
|
113
|
+
- `wheel-test` installs the built wheel and runs the tests against that exact
|
|
114
|
+
wheel, which is the release artifact to be uploaded to PyPI.
|
|
115
|
+
|
|
116
|
+
The `test` and `wheel-test` stages run their tests during `podman build`, so a
|
|
117
|
+
failing test causes the image build to fail. They also have test commands as
|
|
118
|
+
their default container commands, so a successful image can be tested again:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
podman run --rm traderoutes-python-test
|
|
122
|
+
podman run --rm traderoutes-python-wheel-test
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The build stages do not publish anything and do not require PyPI credentials.
|
|
126
|
+
For local development, build directly into a host directory with Podman:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
mkdir -p dist/python
|
|
130
|
+
podman run --rm \
|
|
131
|
+
-v "$PWD:/workspace:Z" \
|
|
132
|
+
-v "$PWD/dist/python:/out:Z" \
|
|
133
|
+
traderoutes-python-build-env \
|
|
134
|
+
python -m build --outdir /out
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
To test the exported wheel in the same clean toolchain:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
podman run --rm \
|
|
141
|
+
-v "$PWD:/workspace:Z" \
|
|
142
|
+
-v "$PWD/dist/python:/dist:Z" \
|
|
143
|
+
traderoutes-python-build-env \
|
|
144
|
+
sh -c 'python -m pip install --no-cache-dir --no-deps /dist/*.whl && python -m unittest discover -s /workspace/tests -v'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Validate the package metadata before uploading:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
podman run --rm \
|
|
151
|
+
-v "$PWD/dist/python:/dist:Z" \
|
|
152
|
+
traderoutes-python-build-env \
|
|
153
|
+
python -m twine check /dist/*
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Local tests
|
|
157
|
+
|
|
158
|
+
With the runtime dependencies installed:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python -m unittest discover -s tests -v
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT; see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# traderoutes for Python
|
|
2
|
+
|
|
3
|
+
`traderoutes` provides country-border queries and weighted shortest-path trade
|
|
4
|
+
routes on a worldwide road, port, and shipping network.
|
|
5
|
+
|
|
6
|
+
The package bundles the precomputed graph assets required at runtime. Users do
|
|
7
|
+
not need the graph-generation code or a container to install and use
|
|
8
|
+
the published package.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
python -m pip install traderoutes
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For development from this repository:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install -e .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The package requires Python 3.11 or newer. Runtime dependencies are declared
|
|
23
|
+
in [`pyproject.toml`](pyproject.toml).
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import traderoutes as tr
|
|
29
|
+
|
|
30
|
+
tr.are_neighbors("FRA", "ITA") # True
|
|
31
|
+
tr.get_neighbors("FRA") # neighboring ISO3 codes
|
|
32
|
+
|
|
33
|
+
origin = tr.get_main_city("FRA")
|
|
34
|
+
destination = tr.get_main_city("NLD")
|
|
35
|
+
path = tr.get_path(origin, destination, road_cost=7, port_cost=1e4)
|
|
36
|
+
|
|
37
|
+
tr.get_pattern(path) # e.g. "crc"
|
|
38
|
+
tr.road_distance(path) # road distance in km
|
|
39
|
+
tr.sea_distance(path) # sea distance in km
|
|
40
|
+
tr.transits_i(path)
|
|
41
|
+
tr.transits_j(path)
|
|
42
|
+
tr.to_kml(path, "fra_nld.kml")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Main API
|
|
46
|
+
|
|
47
|
+
| Function | Description |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `are_neighbors(c1, c2)` | Whether two countries share a land border |
|
|
50
|
+
| `get_neighbors(c)` | Neighboring ISO3 country codes |
|
|
51
|
+
| `get_second_neighbors(c)` | Countries two border hops away |
|
|
52
|
+
| `get_common_neighbors(c1, c2)` | Countries neighboring both inputs |
|
|
53
|
+
| `countries()` | Countries represented in the transportation graph |
|
|
54
|
+
| `get_main_city(c)` | Main city vertex for a country |
|
|
55
|
+
| `get_great_circle(c1, c2)` | Great-circle distance in km |
|
|
56
|
+
| `get_path(v1, v2, ...)` | Weighted shortest path |
|
|
57
|
+
| `get_pattern(path)` | Compact path vertex-type pattern |
|
|
58
|
+
| `get_countries(path, ...)` | Country segments and distances |
|
|
59
|
+
| `road_distance(path)` | Road distance in km |
|
|
60
|
+
| `sea_distance(path)` | Sea distance in km |
|
|
61
|
+
| `transits_i(path, ...)` | Origin-side transit countries |
|
|
62
|
+
| `transits_j(path, ...)` | Destination-side transit countries |
|
|
63
|
+
| `to_kml(path, file)` | Export a path to KML |
|
|
64
|
+
|
|
65
|
+
## Podman targets
|
|
66
|
+
|
|
67
|
+
The [`Dockerfile`](Dockerfile) provides reproducible development, testing, and
|
|
68
|
+
build environments. Build from this directory after retrieving Git LFS files:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git lfs pull
|
|
72
|
+
podman build --target build-env -t traderoutes-python-build-env .
|
|
73
|
+
podman build --target test -t traderoutes-python-test .
|
|
74
|
+
podman build --target build -t traderoutes-python-build .
|
|
75
|
+
podman build --target wheel-test -t traderoutes-python-wheel-test .
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The stages have different purposes:
|
|
79
|
+
|
|
80
|
+
- `build-env` provides only the build toolchain. Mount the source and output
|
|
81
|
+
directories to write artifacts directly to the host. It can also be used as
|
|
82
|
+
an interactive development environment by mounting the source tree.
|
|
83
|
+
- `test` installs the package from the source tree and runs the unit tests.
|
|
84
|
+
- `build` creates the wheel and source distribution under `/dist`.
|
|
85
|
+
- `wheel-test` installs the built wheel and runs the tests against that exact
|
|
86
|
+
wheel, which is the release artifact to be uploaded to PyPI.
|
|
87
|
+
|
|
88
|
+
The `test` and `wheel-test` stages run their tests during `podman build`, so a
|
|
89
|
+
failing test causes the image build to fail. They also have test commands as
|
|
90
|
+
their default container commands, so a successful image can be tested again:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
podman run --rm traderoutes-python-test
|
|
94
|
+
podman run --rm traderoutes-python-wheel-test
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The build stages do not publish anything and do not require PyPI credentials.
|
|
98
|
+
For local development, build directly into a host directory with Podman:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
mkdir -p dist/python
|
|
102
|
+
podman run --rm \
|
|
103
|
+
-v "$PWD:/workspace:Z" \
|
|
104
|
+
-v "$PWD/dist/python:/out:Z" \
|
|
105
|
+
traderoutes-python-build-env \
|
|
106
|
+
python -m build --outdir /out
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
To test the exported wheel in the same clean toolchain:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
podman run --rm \
|
|
113
|
+
-v "$PWD:/workspace:Z" \
|
|
114
|
+
-v "$PWD/dist/python:/dist:Z" \
|
|
115
|
+
traderoutes-python-build-env \
|
|
116
|
+
sh -c 'python -m pip install --no-cache-dir --no-deps /dist/*.whl && python -m unittest discover -s /workspace/tests -v'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Validate the package metadata before uploading:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
podman run --rm \
|
|
123
|
+
-v "$PWD/dist/python:/dist:Z" \
|
|
124
|
+
traderoutes-python-build-env \
|
|
125
|
+
python -m twine check /dist/*
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Local tests
|
|
129
|
+
|
|
130
|
+
With the runtime dependencies installed:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
python -m unittest discover -s tests -v
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT; see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "traderoutes"
|
|
7
|
+
version = "0.0.3"
|
|
8
|
+
description = "Compute weighted international trade routes on a worldwide network."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"numpy>=2.4.0",
|
|
12
|
+
"igraph>=0.11.8",
|
|
13
|
+
"simplekml>=1.3.6",
|
|
14
|
+
"geopy>=2.4.1"
|
|
15
|
+
]
|
|
16
|
+
authors = [
|
|
17
|
+
{name = "Mario Milone", email = "milonemario@gmail.com"}
|
|
18
|
+
]
|
|
19
|
+
license = "MIT"
|
|
20
|
+
license-files = ["LICENSE"]
|
|
21
|
+
readme = "README.md"
|
|
22
|
+
keywords = ["trade routes", "transportation network", "graph", "geography"]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Intended Audience :: Science/Research",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/milonemario/traderoutes"
|
|
37
|
+
Repository = "https://github.com/milonemario/traderoutes"
|
|
38
|
+
Issues = "https://github.com/milonemario/traderoutes/issues"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
traderoutes = ["assets/*.pkl"]
|
|
Binary file
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Module to query about borders information
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
|
|
6
|
+
import igraph as ig
|
|
7
|
+
|
|
8
|
+
# Import the borders graph
|
|
9
|
+
b = ig.read(files("traderoutes.assets").joinpath("bordersgraph.pkl"), format="pickle")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def are_neighbors(country1, country2):
|
|
13
|
+
"""Return True if the two countries share a land border.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
country1 (str): ISO 3-letter code of the first country (e.g. ``"FRA"``).
|
|
17
|
+
country2 (str): ISO 3-letter code of the second country (e.g. ``"ITA"``).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
bool: ``True`` if the countries are direct neighbors, ``False`` otherwise.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
>>> are_neighbors("FRA", "ITA")
|
|
24
|
+
True
|
|
25
|
+
>>> are_neighbors("FRA", "USA")
|
|
26
|
+
False
|
|
27
|
+
"""
|
|
28
|
+
return country2 in get_neighbors(country1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_neighbors(country):
|
|
32
|
+
"""Return the ISO3 codes of all countries sharing a land border with *country*.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
country (str): ISO 3-letter code of the country (e.g. ``"FRA"``).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
list[str]: Sorted list of ISO3 codes of neighboring countries.
|
|
39
|
+
Returns an empty list if the country is not found in the graph.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
>>> get_neighbors("USA")
|
|
43
|
+
['CAN', 'MEX']
|
|
44
|
+
"""
|
|
45
|
+
countries = b.vs["name"]
|
|
46
|
+
neighbors = []
|
|
47
|
+
if country in countries:
|
|
48
|
+
c = b.vs.find(country)
|
|
49
|
+
ns = c.neighbors()
|
|
50
|
+
for n in ns:
|
|
51
|
+
neighbors += [n["name"]]
|
|
52
|
+
return neighbors
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_second_neighbors(country):
|
|
56
|
+
"""Return countries reachable in exactly two border hops from *country*.
|
|
57
|
+
|
|
58
|
+
The returned list excludes *country* itself and its direct neighbors.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
country (str): ISO 3-letter code of the country (e.g. ``"FRA"``).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
list[str]: List of ISO3 codes of second-degree neighbors, in the order
|
|
65
|
+
they are encountered while traversing the border graph.
|
|
66
|
+
Returns an empty list if the country is not found.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
>>> "PRT" in get_second_neighbors("FRA")
|
|
70
|
+
True
|
|
71
|
+
>>> "ITA" in get_second_neighbors("FRA") # direct neighbor — excluded
|
|
72
|
+
False
|
|
73
|
+
"""
|
|
74
|
+
countries = b.vs["name"]
|
|
75
|
+
second_neighbors = []
|
|
76
|
+
neighbors = get_neighbors(country)
|
|
77
|
+
if country in countries:
|
|
78
|
+
c = b.vs.find(country)
|
|
79
|
+
ns = c.neighbors()
|
|
80
|
+
for n in ns:
|
|
81
|
+
sns = n.neighbors()
|
|
82
|
+
for sn in sns:
|
|
83
|
+
name = sn["name"]
|
|
84
|
+
if (
|
|
85
|
+
name not in second_neighbors
|
|
86
|
+
and name not in neighbors
|
|
87
|
+
and name != country
|
|
88
|
+
):
|
|
89
|
+
second_neighbors += [name]
|
|
90
|
+
return second_neighbors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_common_neighbors(country1, country2):
|
|
94
|
+
"""Return countries that share a land border with both *country1* and *country2*.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
country1 (str): ISO 3-letter code of the first country.
|
|
98
|
+
country2 (str): ISO 3-letter code of the second country.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
list[str]: List of ISO3 codes of countries neighboring both inputs.
|
|
102
|
+
Returns an empty list if no common neighbors exist.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
>>> get_common_neighbors("FRA", "ITA")
|
|
106
|
+
['CHE']
|
|
107
|
+
>>> get_common_neighbors("FRA", "USA")
|
|
108
|
+
[]
|
|
109
|
+
"""
|
|
110
|
+
ns1 = get_neighbors(country1)
|
|
111
|
+
ns2 = get_neighbors(country2)
|
|
112
|
+
return [n for n in ns1 if n in ns2]
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
# import logging
|
|
2
|
+
# import os
|
|
3
|
+
# import re
|
|
4
|
+
# import sys
|
|
5
|
+
import warnings
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
import igraph as ig
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
# import pandas as pd
|
|
13
|
+
import simplekml
|
|
14
|
+
from geopy.distance import great_circle
|
|
15
|
+
|
|
16
|
+
from .borders import *
|
|
17
|
+
|
|
18
|
+
SEA_EDGE_TYPES = ("sea", "sea_border")
|
|
19
|
+
BORDER_EDGE_TYPES = ("road_border", "sea_border")
|
|
20
|
+
ROAD_EDGE_TYPES = ("road", "road_sea")
|
|
21
|
+
OPEN_SEA_COUNTRY = "sea"
|
|
22
|
+
|
|
23
|
+
# Load the full graph
|
|
24
|
+
g = ig.load(files("traderoutes.assets").joinpath("fullgraph.pkl"), format="pickle")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clean_country(country):
|
|
28
|
+
if country is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(country, str):
|
|
31
|
+
country = country.strip()
|
|
32
|
+
if country == "" or country == "None":
|
|
33
|
+
return None
|
|
34
|
+
return country
|
|
35
|
+
if isinstance(country, float) and np.isnan(country):
|
|
36
|
+
return None
|
|
37
|
+
return country
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def countries():
|
|
41
|
+
"""Return a sorted list of all countries present in the transportation network.
|
|
42
|
+
|
|
43
|
+
A country is included if it has at least one city vertex (``vtype="city"``)
|
|
44
|
+
in the graph.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
list[str]: Sorted list of ISO 3-letter country codes.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> cs = countries()
|
|
51
|
+
>>> "FRA" in cs
|
|
52
|
+
True
|
|
53
|
+
"""
|
|
54
|
+
return np.unique(g.vs.select(vtype="city")["country"]).tolist()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_main_city(country):
|
|
58
|
+
"""Return the main city vertex for a given country.
|
|
59
|
+
|
|
60
|
+
The main city is the vertex with ``vtype="city"`` and ``maincity=1``
|
|
61
|
+
for the specified country. It is used as the representative location for
|
|
62
|
+
great-circle distance calculations and as the default path endpoint.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
country (str): ISO 3-letter code of the country (e.g. ``"FRA"``).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
igraph.Vertex | None: The main city vertex, or ``None`` if not found.
|
|
69
|
+
|
|
70
|
+
Warns:
|
|
71
|
+
UserWarning: If no main city is found for the country.
|
|
72
|
+
UserWarning: If more than one main city is found (returns the first).
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
>>> v = get_main_city("FRA")
|
|
76
|
+
>>> v["name"]
|
|
77
|
+
'Paris'
|
|
78
|
+
"""
|
|
79
|
+
mcs = g.vs.select(vtype="city", maincity=1, country=country)
|
|
80
|
+
if len(mcs) == 0:
|
|
81
|
+
warnings.warn(f"No main city found for country {country}.")
|
|
82
|
+
return None
|
|
83
|
+
elif len(mcs) > 1:
|
|
84
|
+
warnings.warn(
|
|
85
|
+
f"More than one main city found for country {country}. Return the first one."
|
|
86
|
+
)
|
|
87
|
+
return mcs[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_great_circle(country1, country2):
|
|
91
|
+
"""Return the great-circle distance in km between the main cities of two countries.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
country1 (str): ISO 3-letter code of the origin country.
|
|
95
|
+
country2 (str): ISO 3-letter code of the destination country.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
float: Great-circle distance in kilometres.
|
|
99
|
+
|
|
100
|
+
Examples:
|
|
101
|
+
>>> round(get_great_circle("FRA", "ITA"), 2)
|
|
102
|
+
1109.56
|
|
103
|
+
"""
|
|
104
|
+
city1 = get_main_city(country1)
|
|
105
|
+
city2 = get_main_city(country2)
|
|
106
|
+
return great_circle((city1["lat"], city1["lon"]), (city2["lat"], city2["lon"])).km
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_trade_route_great_circle(country1, country2):
|
|
110
|
+
"""Return a dict with country codes and the great-circle distance between them.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
country1 (str): ISO 3-letter code of the origin country.
|
|
114
|
+
country2 (str): ISO 3-letter code of the destination country.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
dict: Dictionary with keys ``"iso_i"`` (origin), ``"iso_j"`` (destination),
|
|
118
|
+
and ``"dist"`` (great-circle distance in km).
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
>>> r = get_trade_route_great_circle("FRA", "ITA")
|
|
122
|
+
>>> r["iso_i"], r["iso_j"]
|
|
123
|
+
('FRA', 'ITA')
|
|
124
|
+
"""
|
|
125
|
+
return {
|
|
126
|
+
"iso_i": country1,
|
|
127
|
+
"iso_j": country2,
|
|
128
|
+
"dist": get_great_circle(country1, country2),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_path(
|
|
133
|
+
v1,
|
|
134
|
+
v2,
|
|
135
|
+
road_cost=1,
|
|
136
|
+
road_cost_countries={},
|
|
137
|
+
port_cost=1,
|
|
138
|
+
port_cost_countries={},
|
|
139
|
+
border_cost=0,
|
|
140
|
+
border_cost_countries={},
|
|
141
|
+
):
|
|
142
|
+
"""Return the shortest-path trade route between two graph vertices.
|
|
143
|
+
|
|
144
|
+
The returned path corresponds to the shortest path weighted by the costs
|
|
145
|
+
applied to the graph edges, as determined by the arguments.
|
|
146
|
+
|
|
147
|
+
Cost application order:
|
|
148
|
+
|
|
149
|
+
1. All edges are initialized to their true distance (``wdist = dist``).
|
|
150
|
+
2. **Port edges** (``etype="road_sea"``): ``wdist = dist + port_cost``.
|
|
151
|
+
Per-country overrides in *port_cost_countries* take precedence.
|
|
152
|
+
3. **Border edges** (``etype`` in ``("road_border", "sea_border")``):
|
|
153
|
+
``wdist = dist + border_cost``. Per-pair overrides in
|
|
154
|
+
*border_cost_countries* take precedence.
|
|
155
|
+
4. **Road edges** (``etype="road"``): ``wdist = dist * road_cost``.
|
|
156
|
+
Per-country overrides in *road_cost_countries* take precedence.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
v1: Source vertex (igraph Vertex object).
|
|
160
|
+
v2: Destination vertex (igraph Vertex object).
|
|
161
|
+
road_cost (float): Multiplicative cost factor for road edges.
|
|
162
|
+
Applies to all road edges not listed in *road_cost_countries*,
|
|
163
|
+
excluding cross-border road edges. Default: ``1``.
|
|
164
|
+
road_cost_countries (dict[str, float]): Per-country road cost overrides.
|
|
165
|
+
Keys are ISO3 codes; values replace the global *road_cost* for
|
|
166
|
+
road edges fully inside that country. Example: ``{"FRA": 2}``.
|
|
167
|
+
port_cost (float): Additive cost for land-to-sea transitions
|
|
168
|
+
(``road_sea`` edges). Default: ``1``.
|
|
169
|
+
port_cost_countries (dict[str, float]): Per-country port cost overrides.
|
|
170
|
+
Keys are ISO3 codes. Example: ``{"FRA": 2}``.
|
|
171
|
+
border_cost (float): Additive cost for crossing a country border
|
|
172
|
+
(``road_border`` and ``sea_border`` edges). Default: ``0``.
|
|
173
|
+
border_cost_countries (dict[tuple[str, str], float]): Per-country-pair
|
|
174
|
+
border cost overrides. Keys are tuples of ISO3 codes.
|
|
175
|
+
Example: ``{("FRA", "DEU"): 500}``.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
dict: Dictionary with two keys:
|
|
179
|
+
|
|
180
|
+
- ``"epath"`` (list[int]): Edge indices of the shortest path.
|
|
181
|
+
- ``"vpath"`` (list[int]): Vertex indices of the shortest path.
|
|
182
|
+
|
|
183
|
+
Examples:
|
|
184
|
+
>>> v_fra = get_main_city("FRA")
|
|
185
|
+
>>> v_nld = get_main_city("NLD")
|
|
186
|
+
>>> path = get_path(v_fra, v_nld, road_cost=7, port_cost=1e4)
|
|
187
|
+
>>> get_pattern(path)
|
|
188
|
+
'crc'
|
|
189
|
+
"""
|
|
190
|
+
# 0. Initialize all weighted distance at actual distance
|
|
191
|
+
g.es["wdist"] = g.es["dist"]
|
|
192
|
+
# 1. Apply port costs
|
|
193
|
+
ports = g.es.select(etype="road_sea")
|
|
194
|
+
ports["wdist"] = (np.array(ports["dist"]) + port_cost).tolist()
|
|
195
|
+
for c in port_cost_countries:
|
|
196
|
+
ports_c = ports.select(country=c)
|
|
197
|
+
country_port_cost = port_cost_countries[c]
|
|
198
|
+
ports_c["wdist"] = (np.array(ports_c["dist"]) + country_port_cost).tolist()
|
|
199
|
+
# 2. Apply border costs
|
|
200
|
+
borders = g.es.select(etype_in=BORDER_EDGE_TYPES)
|
|
201
|
+
borders["wdist"] = (np.array(borders["dist"]) + border_cost).tolist()
|
|
202
|
+
for cs in border_cost_countries:
|
|
203
|
+
borders_c = borders.select(country1_in=cs, country2_in=cs)
|
|
204
|
+
country_border_cost = border_cost_countries[cs]
|
|
205
|
+
borders_c["wdist"] = (
|
|
206
|
+
np.array(borders_c["dist"]) + country_border_cost
|
|
207
|
+
).tolist()
|
|
208
|
+
# 3. Apply road costs
|
|
209
|
+
roads = g.es.select(etype="road")
|
|
210
|
+
roads["wdist"] = (np.array(roads["dist"]) * road_cost).tolist()
|
|
211
|
+
for c in road_cost_countries:
|
|
212
|
+
roads_c = roads.select(country=c)
|
|
213
|
+
country_road_cost = road_cost_countries[c]
|
|
214
|
+
roads_c["wdist"] = (np.array(roads_c["dist"]) * country_road_cost).tolist()
|
|
215
|
+
|
|
216
|
+
# Compute shortest path
|
|
217
|
+
epath = g.get_shortest_paths(v1, v2, weights="wdist", output="epath")[0]
|
|
218
|
+
vpath = g.get_shortest_paths(v1, v2, weights="wdist", output="vpath")[0]
|
|
219
|
+
|
|
220
|
+
return {"epath": epath, "vpath": vpath}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_pattern(path):
|
|
224
|
+
"""Return a string encoding the sequence of vertex types along a path.
|
|
225
|
+
|
|
226
|
+
The pattern compresses consecutive vertices of the same type into a single
|
|
227
|
+
character: ``c`` = city, ``r`` = road, ``p`` = port, ``s`` = sea.
|
|
228
|
+
Intermediate city vertices (i.e. not the first or last vertex) are treated
|
|
229
|
+
as road vertices so they do not break the road segment pattern.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
path (dict): Path dict as returned by :func:`get_path`, with key
|
|
233
|
+
``"vpath"`` containing vertex indices.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
str: Pattern string, e.g. ``"crc"`` (land route), ``"csrc"``
|
|
237
|
+
(sea route with sea from origin side).
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
>>> path = get_path(get_main_city("FRA"), get_main_city("DEU"), road_cost=7)
|
|
241
|
+
>>> get_pattern(path)
|
|
242
|
+
'crc'
|
|
243
|
+
"""
|
|
244
|
+
vpath = path["vpath"]
|
|
245
|
+
vtypes = [g.vs.select(vpath)[0]["vtype"]]
|
|
246
|
+
for v in g.vs.select(vpath)[1:-1]:
|
|
247
|
+
vtype = v["vtype"]
|
|
248
|
+
if vtype == "city":
|
|
249
|
+
vtype = "road"
|
|
250
|
+
if vtype != vtypes[-1]:
|
|
251
|
+
vtypes.append(vtype)
|
|
252
|
+
vtypes.append(g.vs.select(vpath)[-1]["vtype"])
|
|
253
|
+
|
|
254
|
+
return "".join(
|
|
255
|
+
list(map({"city": "c", "port": "p", "road": "r", "sea": "s"}.get, vtypes))
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_countries(path, with_sea=False, clean_dist=None):
|
|
260
|
+
"""Return the sequence of countries traversed along a path.
|
|
261
|
+
|
|
262
|
+
Each entry in the returned list is a ``(country, distance)`` tuple, where
|
|
263
|
+
*distance* is the total distance (km) traveled within that country segment.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
path (dict): Path dict as returned by :func:`get_path`.
|
|
267
|
+
with_sea (bool): If ``True``, sea segments are included as entries with
|
|
268
|
+
country code ``"sea"``. Default: ``False``.
|
|
269
|
+
clean_dist (float | None): If set, iteratively removes intermediate
|
|
270
|
+
countries whose segment distance is below *clean_dist* km, until
|
|
271
|
+
all remaining intermediate segments are at or above the threshold
|
|
272
|
+
(or only the origin and destination remain). Default: ``None``.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
list[tuple[str, float]]: List of ``(country_iso3, distance_km)`` tuples
|
|
276
|
+
in traversal order. Sea segments appear as ``("sea", distance)`` when
|
|
277
|
+
*with_sea* is ``True``.
|
|
278
|
+
|
|
279
|
+
Examples:
|
|
280
|
+
>>> path = get_path(get_main_city("FRA"), get_main_city("POL"), road_cost=7)
|
|
281
|
+
>>> [c for c, _ in get_countries(path)]
|
|
282
|
+
['FRA', 'DEU', 'POL']
|
|
283
|
+
"""
|
|
284
|
+
vpath = path["vpath"]
|
|
285
|
+
countries = []
|
|
286
|
+
vs = g.vs.select(vpath)
|
|
287
|
+
c = _clean_country(vs[0]["country"])
|
|
288
|
+
d = 0
|
|
289
|
+
for i in range(1, len(vs)):
|
|
290
|
+
d += g.es.select(_source=vpath[i - 1], _target=vpath[i])[0]["dist"]
|
|
291
|
+
next_c = _clean_country(vs[i]["country"])
|
|
292
|
+
if next_c != c:
|
|
293
|
+
segment_country = OPEN_SEA_COUNTRY if c is None and with_sea else c
|
|
294
|
+
if segment_country is not None:
|
|
295
|
+
countries += [(segment_country, d)]
|
|
296
|
+
c = next_c
|
|
297
|
+
d = 0
|
|
298
|
+
segment_country = OPEN_SEA_COUNTRY if c is None and with_sea else c
|
|
299
|
+
if segment_country is not None:
|
|
300
|
+
countries += [(segment_country, d)]
|
|
301
|
+
if clean_dist is not None:
|
|
302
|
+
cleaned = False
|
|
303
|
+
while not cleaned and len(countries) > 2:
|
|
304
|
+
dists = [c[1] for c in countries]
|
|
305
|
+
min_d = min(dists[1:-1])
|
|
306
|
+
if min_d < clean_dist:
|
|
307
|
+
countries = [
|
|
308
|
+
countries[i]
|
|
309
|
+
for i in range(0, len(countries))
|
|
310
|
+
if i != dists.index(min_d)
|
|
311
|
+
]
|
|
312
|
+
# Consolidate consecutive same-country segments
|
|
313
|
+
countries_new = []
|
|
314
|
+
cd = countries[0]
|
|
315
|
+
for i in range(1, len(countries)):
|
|
316
|
+
if countries[i][0] == cd[0]:
|
|
317
|
+
cd = (cd[0], cd[1] + countries[i][1])
|
|
318
|
+
else:
|
|
319
|
+
countries_new += [cd]
|
|
320
|
+
cd = countries[i]
|
|
321
|
+
countries_new += [cd]
|
|
322
|
+
countries = countries_new
|
|
323
|
+
else:
|
|
324
|
+
cleaned = True
|
|
325
|
+
|
|
326
|
+
return countries
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def road_distance(path):
|
|
330
|
+
"""Return the total road distance (km) along a path.
|
|
331
|
+
|
|
332
|
+
Sums the ``dist`` attribute of all edges with ``etype`` in
|
|
333
|
+
``("road", "road_sea")``.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
path (dict): Path dict as returned by :func:`get_path`.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
float: Total road distance in kilometres.
|
|
340
|
+
"""
|
|
341
|
+
roads = g.es.select(path["epath"]).select(etype_in=ROAD_EDGE_TYPES)
|
|
342
|
+
return sum(roads["dist"])
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def sea_distance(path):
|
|
346
|
+
"""Return the total sea distance (km) along a path.
|
|
347
|
+
|
|
348
|
+
Sums the ``dist`` attribute of all edges with ``etype`` in
|
|
349
|
+
``("sea", "sea_border")``.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
path (dict): Path dict as returned by :func:`get_path`.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
float: Total sea distance in kilometres.
|
|
356
|
+
"""
|
|
357
|
+
seas = g.es.select(path["epath"]).select(etype_in=SEA_EDGE_TYPES)
|
|
358
|
+
return sum(seas["dist"])
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def transits_i(path, clean_dist=None):
|
|
362
|
+
"""Return transit country ISO3 codes from the origin side of a path.
|
|
363
|
+
|
|
364
|
+
For **land-only** routes (no sea segment): all intermediate countries
|
|
365
|
+
between origin and destination are returned.
|
|
366
|
+
|
|
367
|
+
For routes **with sea**: only countries between the origin and the first
|
|
368
|
+
sea segment are returned.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
path (dict): Path dict as returned by :func:`get_path`.
|
|
372
|
+
clean_dist (float | None): Passed to :func:`get_countries` to remove
|
|
373
|
+
very short country segments. Default: ``None``.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
list[str]: ISO3 codes of transit countries on the origin side,
|
|
377
|
+
in traversal order.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
Exception: If the sea is used more than once in the path.
|
|
381
|
+
|
|
382
|
+
Examples:
|
|
383
|
+
>>> path = get_path(get_main_city("CHE"), get_main_city("POL"), road_cost=7)
|
|
384
|
+
>>> transits_i(path) # Switzerland → (DEU or AUT) → Poland
|
|
385
|
+
['DEU']
|
|
386
|
+
"""
|
|
387
|
+
pattern = get_pattern(path)
|
|
388
|
+
if "s" not in pattern:
|
|
389
|
+
cs = [c[0] for c in get_countries(path, clean_dist=clean_dist)]
|
|
390
|
+
return cs[1:-1]
|
|
391
|
+
else:
|
|
392
|
+
if pattern.count("s") > 1:
|
|
393
|
+
raise Exception(
|
|
394
|
+
f"Cannot compute transit countries for pattern '{pattern}' because sea is used more than once."
|
|
395
|
+
)
|
|
396
|
+
cs = [c[0] for c in get_countries(path, with_sea=True, clean_dist=clean_dist)]
|
|
397
|
+
if OPEN_SEA_COUNTRY not in cs:
|
|
398
|
+
return cs[1:-1]
|
|
399
|
+
return cs[1 : cs.index(OPEN_SEA_COUNTRY)]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def transits_j(path, clean_dist=None):
|
|
403
|
+
"""Return transit country ISO3 codes from the destination side of a path.
|
|
404
|
+
|
|
405
|
+
Equivalent to :func:`transits_i` applied to the reversed path, identifying
|
|
406
|
+
countries between the destination and the last sea segment (or all
|
|
407
|
+
intermediate countries for land-only routes).
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
path (dict): Path dict as returned by :func:`get_path`.
|
|
411
|
+
clean_dist (float | None): Passed to :func:`get_countries` to remove
|
|
412
|
+
very short country segments. Default: ``None``.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
list[str]: ISO3 codes of transit countries on the destination side,
|
|
416
|
+
in reverse traversal order (i.e. destination-outward).
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
Exception: If the sea is used more than once in the path.
|
|
420
|
+
"""
|
|
421
|
+
path_reversed = {
|
|
422
|
+
"vpath": list(reversed(path["vpath"])),
|
|
423
|
+
"epath": list(reversed(path["epath"])),
|
|
424
|
+
}
|
|
425
|
+
return transits_i(path_reversed, clean_dist=clean_dist)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
######################
|
|
429
|
+
# Plotting Functions #
|
|
430
|
+
######################
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def to_kml(path, filename):
|
|
434
|
+
"""Export a trade route path to a KML file for viewing in Google Earth.
|
|
435
|
+
|
|
436
|
+
The path is drawn as a red line connecting consecutive vertices.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
path (dict): Path dict as returned by :func:`get_path`.
|
|
440
|
+
filename (str): Output file path (e.g. ``"route.kml"``).
|
|
441
|
+
|
|
442
|
+
Examples:
|
|
443
|
+
>>> path = get_path(get_main_city("FRA"), get_main_city("NLD"), road_cost=7)
|
|
444
|
+
>>> to_kml(path, "fra_nld.kml")
|
|
445
|
+
"""
|
|
446
|
+
vpath = path["vpath"]
|
|
447
|
+
kml = simplekml.Kml()
|
|
448
|
+
coord0 = (g.vs[vpath[0]]["lon"], g.vs[vpath[0]]["lat"])
|
|
449
|
+
for i in range(1, len(vpath)):
|
|
450
|
+
coord1 = (g.vs[vpath[i]]["lon"], g.vs[vpath[i]]["lat"])
|
|
451
|
+
ls = kml.newlinestring(coords=[coord0, coord1])
|
|
452
|
+
ls.style.linestyle.width = 5
|
|
453
|
+
ls.style.linestyle.color = simplekml.Color.red
|
|
454
|
+
coord0 = coord1
|
|
455
|
+
kml.save(filename)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def cities_to_kml(filename, all_cities=False):
|
|
459
|
+
"""Export city locations to a KML file, organised by country folder.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
filename (str): Output file path.
|
|
463
|
+
all_cities (bool): If ``False`` (default), only main cities are exported.
|
|
464
|
+
If ``True``, all city vertices are exported.
|
|
465
|
+
"""
|
|
466
|
+
kml = simplekml.Kml()
|
|
467
|
+
if all_cities:
|
|
468
|
+
cities = g.vs.select(vtype="city")
|
|
469
|
+
else:
|
|
470
|
+
cities = g.vs.select(vtype="city", maincity=True)
|
|
471
|
+
|
|
472
|
+
for co in countries():
|
|
473
|
+
fc = kml.newfolder(name=co)
|
|
474
|
+
for c in cities.select(country=co):
|
|
475
|
+
coord = (c["lon"], c["lat"])
|
|
476
|
+
pt = kml.newpoint(name=f"{c['name']} - {c['country']}", coords=[coord])
|
|
477
|
+
pt.style.iconstyle.color = simplekml.Color.yellow
|
|
478
|
+
kml.save(filename)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def ports_to_kml(filename):
|
|
482
|
+
"""Export port locations to a KML file, organised by country folder.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
filename (str): Output file path.
|
|
486
|
+
"""
|
|
487
|
+
kml = simplekml.Kml()
|
|
488
|
+
ports = g.vs.select(vtype="port")
|
|
489
|
+
for c in countries():
|
|
490
|
+
fc = kml.newfolder(name=c)
|
|
491
|
+
for p in ports.select(country=c):
|
|
492
|
+
coord = (p["lon"], p["lat"])
|
|
493
|
+
pt = fc.newpoint(name=f"{p['name']} - {p['country']}", coords=[coord])
|
|
494
|
+
pt.style.iconstyle.color = simplekml.Color.aqua
|
|
495
|
+
kml.save(filename)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def roads_to_kml(filename):
|
|
499
|
+
"""Export the road network to a KMZ file, organised by country folder.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
filename (str): Output file path (without extension; ``.kmz`` is appended).
|
|
503
|
+
"""
|
|
504
|
+
kml = simplekml.Kml()
|
|
505
|
+
edges = g.es.select(etype_in=ROAD_EDGE_TYPES)
|
|
506
|
+
road_countries = np.unique(g.vs.select(vtype="road")["country"])
|
|
507
|
+
|
|
508
|
+
for c in road_countries.tolist() + [None]:
|
|
509
|
+
print(c, end="\r")
|
|
510
|
+
es = edges.select(country=c)
|
|
511
|
+
fc = kml.newfolder(name=c)
|
|
512
|
+
for e in es:
|
|
513
|
+
coords_source = (g.vs[e.source]["lon"], g.vs[e.source]["lat"])
|
|
514
|
+
coords_target = (g.vs[e.target]["lon"], g.vs[e.target]["lat"])
|
|
515
|
+
ls = fc.newlinestring(coords=[coords_source, coords_target])
|
|
516
|
+
ls.style.linestyle.width = 1
|
|
517
|
+
ls.style.linestyle.color = simplekml.Color.yellow
|
|
518
|
+
|
|
519
|
+
print("Save file")
|
|
520
|
+
kml.savekmz(f"{filename}.kmz", format=False)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def seas_to_kml(filename):
|
|
524
|
+
"""Export the sea-lane network, including sea-border edges, to a KMZ file.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
filename (str): Output file path (without extension; ``.kmz`` is appended).
|
|
528
|
+
"""
|
|
529
|
+
kml = simplekml.Kml()
|
|
530
|
+
edges = g.es.select(etype_in=SEA_EDGE_TYPES)
|
|
531
|
+
|
|
532
|
+
for e in edges:
|
|
533
|
+
coords_source = (g.vs[e.source]["lon"], g.vs[e.source]["lat"])
|
|
534
|
+
coords_target = (g.vs[e.target]["lon"], g.vs[e.target]["lat"])
|
|
535
|
+
ls = kml.newlinestring(coords=[coords_source, coords_target])
|
|
536
|
+
ls.style.linestyle.width = 1
|
|
537
|
+
ls.style.linestyle.color = simplekml.Color.cyan
|
|
538
|
+
|
|
539
|
+
kml.savekmz(f"{filename}.kmz", format=False)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: traderoutes
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Compute weighted international trade routes on a worldwide network.
|
|
5
|
+
Author-email: Mario Milone <milonemario@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/milonemario/traderoutes
|
|
8
|
+
Project-URL: Repository, https://github.com/milonemario/traderoutes
|
|
9
|
+
Project-URL: Issues, https://github.com/milonemario/traderoutes/issues
|
|
10
|
+
Keywords: trade routes,transportation network,graph,geography
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=2.4.0
|
|
24
|
+
Requires-Dist: igraph>=0.11.8
|
|
25
|
+
Requires-Dist: simplekml>=1.3.6
|
|
26
|
+
Requires-Dist: geopy>=2.4.1
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# traderoutes for Python
|
|
30
|
+
|
|
31
|
+
`traderoutes` provides country-border queries and weighted shortest-path trade
|
|
32
|
+
routes on a worldwide road, port, and shipping network.
|
|
33
|
+
|
|
34
|
+
The package bundles the precomputed graph assets required at runtime. Users do
|
|
35
|
+
not need the graph-generation code or a container to install and use
|
|
36
|
+
the published package.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install traderoutes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For development from this repository:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The package requires Python 3.11 or newer. Runtime dependencies are declared
|
|
51
|
+
in [`pyproject.toml`](pyproject.toml).
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import traderoutes as tr
|
|
57
|
+
|
|
58
|
+
tr.are_neighbors("FRA", "ITA") # True
|
|
59
|
+
tr.get_neighbors("FRA") # neighboring ISO3 codes
|
|
60
|
+
|
|
61
|
+
origin = tr.get_main_city("FRA")
|
|
62
|
+
destination = tr.get_main_city("NLD")
|
|
63
|
+
path = tr.get_path(origin, destination, road_cost=7, port_cost=1e4)
|
|
64
|
+
|
|
65
|
+
tr.get_pattern(path) # e.g. "crc"
|
|
66
|
+
tr.road_distance(path) # road distance in km
|
|
67
|
+
tr.sea_distance(path) # sea distance in km
|
|
68
|
+
tr.transits_i(path)
|
|
69
|
+
tr.transits_j(path)
|
|
70
|
+
tr.to_kml(path, "fra_nld.kml")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Main API
|
|
74
|
+
|
|
75
|
+
| Function | Description |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `are_neighbors(c1, c2)` | Whether two countries share a land border |
|
|
78
|
+
| `get_neighbors(c)` | Neighboring ISO3 country codes |
|
|
79
|
+
| `get_second_neighbors(c)` | Countries two border hops away |
|
|
80
|
+
| `get_common_neighbors(c1, c2)` | Countries neighboring both inputs |
|
|
81
|
+
| `countries()` | Countries represented in the transportation graph |
|
|
82
|
+
| `get_main_city(c)` | Main city vertex for a country |
|
|
83
|
+
| `get_great_circle(c1, c2)` | Great-circle distance in km |
|
|
84
|
+
| `get_path(v1, v2, ...)` | Weighted shortest path |
|
|
85
|
+
| `get_pattern(path)` | Compact path vertex-type pattern |
|
|
86
|
+
| `get_countries(path, ...)` | Country segments and distances |
|
|
87
|
+
| `road_distance(path)` | Road distance in km |
|
|
88
|
+
| `sea_distance(path)` | Sea distance in km |
|
|
89
|
+
| `transits_i(path, ...)` | Origin-side transit countries |
|
|
90
|
+
| `transits_j(path, ...)` | Destination-side transit countries |
|
|
91
|
+
| `to_kml(path, file)` | Export a path to KML |
|
|
92
|
+
|
|
93
|
+
## Podman targets
|
|
94
|
+
|
|
95
|
+
The [`Dockerfile`](Dockerfile) provides reproducible development, testing, and
|
|
96
|
+
build environments. Build from this directory after retrieving Git LFS files:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git lfs pull
|
|
100
|
+
podman build --target build-env -t traderoutes-python-build-env .
|
|
101
|
+
podman build --target test -t traderoutes-python-test .
|
|
102
|
+
podman build --target build -t traderoutes-python-build .
|
|
103
|
+
podman build --target wheel-test -t traderoutes-python-wheel-test .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The stages have different purposes:
|
|
107
|
+
|
|
108
|
+
- `build-env` provides only the build toolchain. Mount the source and output
|
|
109
|
+
directories to write artifacts directly to the host. It can also be used as
|
|
110
|
+
an interactive development environment by mounting the source tree.
|
|
111
|
+
- `test` installs the package from the source tree and runs the unit tests.
|
|
112
|
+
- `build` creates the wheel and source distribution under `/dist`.
|
|
113
|
+
- `wheel-test` installs the built wheel and runs the tests against that exact
|
|
114
|
+
wheel, which is the release artifact to be uploaded to PyPI.
|
|
115
|
+
|
|
116
|
+
The `test` and `wheel-test` stages run their tests during `podman build`, so a
|
|
117
|
+
failing test causes the image build to fail. They also have test commands as
|
|
118
|
+
their default container commands, so a successful image can be tested again:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
podman run --rm traderoutes-python-test
|
|
122
|
+
podman run --rm traderoutes-python-wheel-test
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The build stages do not publish anything and do not require PyPI credentials.
|
|
126
|
+
For local development, build directly into a host directory with Podman:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
mkdir -p dist/python
|
|
130
|
+
podman run --rm \
|
|
131
|
+
-v "$PWD:/workspace:Z" \
|
|
132
|
+
-v "$PWD/dist/python:/out:Z" \
|
|
133
|
+
traderoutes-python-build-env \
|
|
134
|
+
python -m build --outdir /out
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
To test the exported wheel in the same clean toolchain:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
podman run --rm \
|
|
141
|
+
-v "$PWD:/workspace:Z" \
|
|
142
|
+
-v "$PWD/dist/python:/dist:Z" \
|
|
143
|
+
traderoutes-python-build-env \
|
|
144
|
+
sh -c 'python -m pip install --no-cache-dir --no-deps /dist/*.whl && python -m unittest discover -s /workspace/tests -v'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Validate the package metadata before uploading:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
podman run --rm \
|
|
151
|
+
-v "$PWD/dist/python:/dist:Z" \
|
|
152
|
+
traderoutes-python-build-env \
|
|
153
|
+
python -m twine check /dist/*
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Local tests
|
|
157
|
+
|
|
158
|
+
With the runtime dependencies installed:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python -m unittest discover -s tests -v
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT; see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/traderoutes/__init__.py
|
|
5
|
+
src/traderoutes/borders.py
|
|
6
|
+
src/traderoutes/routes.py
|
|
7
|
+
src/traderoutes.egg-info/PKG-INFO
|
|
8
|
+
src/traderoutes.egg-info/SOURCES.txt
|
|
9
|
+
src/traderoutes.egg-info/dependency_links.txt
|
|
10
|
+
src/traderoutes.egg-info/requires.txt
|
|
11
|
+
src/traderoutes.egg-info/top_level.txt
|
|
12
|
+
src/traderoutes/assets/bordersgraph.pkl
|
|
13
|
+
src/traderoutes/assets/fullgraph.pkl
|
|
14
|
+
tests/test_borders.py
|
|
15
|
+
tests/test_routes.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
traderoutes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from traderoutes.borders import *
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestBorders(unittest.TestCase):
|
|
7
|
+
|
|
8
|
+
def test_get_neighbors(self):
|
|
9
|
+
self.assertEqual(get_neighbors("USA"), ["MEX", "CAN"])
|
|
10
|
+
|
|
11
|
+
def test_are_neighbors(self):
|
|
12
|
+
self.assertEqual(are_neighbors("FRA", "ITA"), True)
|
|
13
|
+
self.assertEqual(are_neighbors("FRA", "USA"), False)
|
|
14
|
+
|
|
15
|
+
def test_get_second_neighbors(self):
|
|
16
|
+
self.assertEqual(
|
|
17
|
+
get_second_neighbors("FRA"),
|
|
18
|
+
[
|
|
19
|
+
"MAR",
|
|
20
|
+
"PRT",
|
|
21
|
+
"AUT",
|
|
22
|
+
"CZE",
|
|
23
|
+
"POL",
|
|
24
|
+
"NLD",
|
|
25
|
+
"DNK",
|
|
26
|
+
"SVN",
|
|
27
|
+
"SMR",
|
|
28
|
+
"VAT",
|
|
29
|
+
"LIE",
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def test_get_common_neighbors(self):
|
|
34
|
+
self.assertEqual(get_common_neighbors("FRA", "ITA"), ["CHE"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main":
|
|
38
|
+
unittest.main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from traderoutes.routes import *
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRoutes(unittest.TestCase):
|
|
10
|
+
def _trade_path(self, country1, country2, border_cost=0, border_cost_countries=None):
|
|
11
|
+
if border_cost_countries is None:
|
|
12
|
+
border_cost_countries = {}
|
|
13
|
+
return get_path(
|
|
14
|
+
get_main_city(country1),
|
|
15
|
+
get_main_city(country2),
|
|
16
|
+
road_cost=7,
|
|
17
|
+
port_cost=1e4,
|
|
18
|
+
port_cost_countries={country1: 5e3, country2: 5e3},
|
|
19
|
+
border_cost=border_cost,
|
|
20
|
+
border_cost_countries=border_cost_countries,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def test_get_main_city(self):
|
|
24
|
+
# Check only one main city per country
|
|
25
|
+
countries = np.unique(g.vs.select(vtype="city")["country"])
|
|
26
|
+
with warnings.catch_warnings(record=True) as warnings_log:
|
|
27
|
+
warnings.simplefilter("always")
|
|
28
|
+
for c in countries:
|
|
29
|
+
mc = get_main_city(c)
|
|
30
|
+
self.assertEqual(
|
|
31
|
+
len(warnings_log),
|
|
32
|
+
0,
|
|
33
|
+
f"Unexpected warnings: {[w.message for w in warnings_log]}",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def test_get_great_circle(self):
|
|
37
|
+
self.assertAlmostEqual(get_great_circle("FRA", "ITA"), 1109.56, places=2)
|
|
38
|
+
|
|
39
|
+
def test_get_trade_route_great_circle(self):
|
|
40
|
+
tr = get_trade_route_great_circle("FRA", "ITA")
|
|
41
|
+
self.assertEqual(tr["iso_i"], "FRA")
|
|
42
|
+
self.assertEqual(tr["iso_j"], "ITA")
|
|
43
|
+
self.assertAlmostEqual(tr["dist"], 1109.56, places=2)
|
|
44
|
+
|
|
45
|
+
def test_sea_distance_includes_sea_border(self):
|
|
46
|
+
path = self._trade_path("PRY", "USA")
|
|
47
|
+
edges = g.es.select(path["epath"])
|
|
48
|
+
self.assertIn("sea_border", edges["etype"])
|
|
49
|
+
|
|
50
|
+
expected = sum(e["dist"] for e in edges if e["etype"] in SEA_EDGE_TYPES)
|
|
51
|
+
self.assertAlmostEqual(sea_distance(path), expected)
|
|
52
|
+
|
|
53
|
+
def test_paraguay_usa_origin_transits(self):
|
|
54
|
+
path = self._trade_path("PRY", "USA")
|
|
55
|
+
ti = transits_i(path)
|
|
56
|
+
tj = transits_j(path)
|
|
57
|
+
|
|
58
|
+
self.assertGreater(len(ti), 0)
|
|
59
|
+
self.assertIn("ARG", ti)
|
|
60
|
+
self.assertNotIn("PRY", ti + tj)
|
|
61
|
+
self.assertNotIn("USA", ti + tj)
|
|
62
|
+
|
|
63
|
+
def test_sea_border_country_pair_costs(self):
|
|
64
|
+
self._trade_path(
|
|
65
|
+
"PRY",
|
|
66
|
+
"USA",
|
|
67
|
+
border_cost=1e4,
|
|
68
|
+
border_cost_countries={("ARG", "PRY"): 5e4},
|
|
69
|
+
)
|
|
70
|
+
edges = g.es.select(
|
|
71
|
+
etype="sea_border", country1_in=("ARG", "PRY"), country2_in=("ARG", "PRY")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.assertGreater(len(edges), 0)
|
|
75
|
+
for edge in edges:
|
|
76
|
+
self.assertAlmostEqual(edge["wdist"], edge["dist"] + 5e4)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main":
|
|
80
|
+
unittest.main()
|