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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .borders import *
2
+ from .routes import *
3
+
4
+ # __all__ = []
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: traderoutes-0.0.3/src/traderoutes/assets/fullgraph.pkl
3
+ size: 43837293 bytes
4
+ sha256: 2b052401007f6095ef5966a84c741a89af2835b9df69a17e8f1e06cfae7b929a
@@ -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,4 @@
1
+ numpy>=2.4.0
2
+ igraph>=0.11.8
3
+ simplekml>=1.3.6
4
+ geopy>=2.4.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()