ga-graph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ga_graph-0.1.0.dist-info/METADATA +200 -0
- ga_graph-0.1.0.dist-info/RECORD +23 -0
- ga_graph-0.1.0.dist-info/WHEEL +5 -0
- ga_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
- ga_graph-0.1.0.dist-info/top_level.txt +1 -0
- graph/__init__.py +39 -0
- graph/_version.py +3 -0
- graph/algorithms/TD_LBSP.py +302 -0
- graph/algorithms/__init__.py +3 -0
- graph/graphs/__init__.py +28 -0
- graph/graphs/action_policy.py +27 -0
- graph/graphs/exceptions.py +35 -0
- graph/graphs/graph.py +446 -0
- graph/graphs/link.py +52 -0
- graph/graphs/node.py +40 -0
- graph/graphs/turn.py +194 -0
- graph/paths/__init__.py +13 -0
- graph/paths/k_path_container.py +83 -0
- graph/paths/k_path_list.py +178 -0
- graph/paths/path.py +83 -0
- graph/paths/path_container.py +109 -0
- graph/paths/path_list.py +126 -0
- graph/py.typed +1 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ga-graph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight graph, turn, path, and time-dependent routing utilities.
|
|
5
|
+
Author: GA Graph contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://github.com/andreagemma/graph#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/andreagemma/graph/issues
|
|
9
|
+
Project-URL: Source, https://github.com/andreagemma/graph
|
|
10
|
+
Keywords: ga-graph,graph,routing,shortest-path,transport-network
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: dill>=0.3.8
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=5.1; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# GA Graph
|
|
38
|
+
|
|
39
|
+
[](https://github.com/andreagemma/graph/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/ga-graph/)
|
|
41
|
+
[](https://pypi.org/project/ga-graph/)
|
|
42
|
+
|
|
43
|
+
GA Graph provides lightweight dictionary-backed graph containers, path
|
|
44
|
+
collections, turn classification helpers, and a link-based time-dependent
|
|
45
|
+
shortest-path router.
|
|
46
|
+
|
|
47
|
+
The PyPI distribution is named `ga-graph`; the import package is named `graph`.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install ga-graph
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Development and test tools are available as extras:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m pip install -e ".[test]"
|
|
59
|
+
python -m pip install -e ".[dev]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from graph import Graph
|
|
66
|
+
|
|
67
|
+
network = Graph(total_time=60, delta_t=15)
|
|
68
|
+
network.add_node("A")
|
|
69
|
+
network.add_node("B")
|
|
70
|
+
network.add_link("ab", "A", "B", cost=5)
|
|
71
|
+
|
|
72
|
+
assert network.get_link("ab").i == "A"
|
|
73
|
+
assert network.get_node_degree("A", include_in_links=False) == 1
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Graphs
|
|
77
|
+
|
|
78
|
+
`Graph` stores nodes, directed links, and turns in dictionary-backed containers.
|
|
79
|
+
Each entity keeps a required identifier plus arbitrary extra attributes.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from graph import ActionPolicy, Graph
|
|
83
|
+
|
|
84
|
+
network = Graph()
|
|
85
|
+
network.add_link(
|
|
86
|
+
"ab",
|
|
87
|
+
"A",
|
|
88
|
+
"B",
|
|
89
|
+
on_missing_node=ActionPolicy.REPLACE,
|
|
90
|
+
capacity=1200,
|
|
91
|
+
)
|
|
92
|
+
network.add_turn("ab_bc", "ab", "bc", on_missing_link=ActionPolicy.IGNORE)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Duplicate and missing references can be handled with `ActionPolicy.RAISE`,
|
|
96
|
+
`WARN`, `IGNORE`, `REPLACE`, or `SKIP`, depending on the method.
|
|
97
|
+
|
|
98
|
+
## Paths
|
|
99
|
+
|
|
100
|
+
`Path` stores a source, target, departure time, optional mode, link sequence, and
|
|
101
|
+
cumulative costs. `PathList` stores one path per `(source, target, t_start,
|
|
102
|
+
mode)` key. `KPathList` stores multiple ranked paths for the same key.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from graph import KPathList, Path
|
|
106
|
+
|
|
107
|
+
paths = KPathList()
|
|
108
|
+
paths.add_path(Path("A", "C", 0, links=["ab", "bc"], costs=[5, 9], mode="car"))
|
|
109
|
+
paths.add_path(Path("A", "C", 0, links=["ac"], costs=[12], mode="car"))
|
|
110
|
+
|
|
111
|
+
best = paths.path("A", "C", 0, "car", k=0)
|
|
112
|
+
second = paths.path("A", "C", 0, "car", k=1)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Time-Dependent Routing
|
|
116
|
+
|
|
117
|
+
`TimeDependentLinkBasedShortestPath` builds a dense routing view from a `Graph`.
|
|
118
|
+
Scalar costs are expanded across the graph time horizon; sequence costs are used
|
|
119
|
+
as piecewise-constant time profiles.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from graph import Graph, TimeDependentLinkBasedShortestPath
|
|
123
|
+
|
|
124
|
+
network = Graph(total_time=60, delta_t=15)
|
|
125
|
+
for node in ["A", "B", "C"]:
|
|
126
|
+
network.add_node(node, cost=0)
|
|
127
|
+
|
|
128
|
+
network.add_link("ab", "A", "B", cost=1)
|
|
129
|
+
network.add_link("bc", "B", "C", cost=1)
|
|
130
|
+
network.add_link("ac", "A", "C", cost=10)
|
|
131
|
+
|
|
132
|
+
router = TimeDependentLinkBasedShortestPath.from_graph(network, link_cost_field="cost")
|
|
133
|
+
path = router.shortest_paths("A", "C").path("A", "C", 0, None)
|
|
134
|
+
|
|
135
|
+
assert path.get_links() == ("ab", "bc")
|
|
136
|
+
assert path["tot_cost"] == 2
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The router supports optional mode fields, node costs, turn costs, and prohibited
|
|
140
|
+
turn fields.
|
|
141
|
+
|
|
142
|
+
## API Summary
|
|
143
|
+
|
|
144
|
+
- `Graph(t0=0, total_time=60, delta_t=15, **kwargs)`
|
|
145
|
+
- `Graph.add_node(idx, on_existing=ActionPolicy.RAISE, **kwargs)`
|
|
146
|
+
- `Graph.add_link(idx, i, j, on_existing=..., on_missing_node=..., **kwargs)`
|
|
147
|
+
- `Graph.add_turn(idx, in_link, out_link, on_existing=..., on_missing_link=..., **kwargs)`
|
|
148
|
+
- `Graph.get_node(idx)`, `Graph.get_link(idx)`, `Graph.get_turn(idx)`
|
|
149
|
+
- `Graph.remove_node(idx, cascade=False)`, `Graph.remove_link(idx, cascade=False)`
|
|
150
|
+
- `Graph.remove_redundants()`
|
|
151
|
+
- `Path(source, target, t_start, links=None, costs=None, mode=None, ...)`
|
|
152
|
+
- `PathList()`
|
|
153
|
+
- `KPathList()`
|
|
154
|
+
- `TurnType.parse(value)` and `TurnType.classify_turn(...)`
|
|
155
|
+
- `TimeDependentLinkBasedShortestPath.from_graph(graph, ...)`
|
|
156
|
+
- `TimeDependentLinkBasedShortestPath.shortest_paths(source, targets=None, t_start=0)`
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
GA Graph supports Python 3.10 and newer.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
python -m pip install -e ".[dev]"
|
|
164
|
+
python -m compileall -q src
|
|
165
|
+
python -m pytest --cov=graph --cov-report=term-missing
|
|
166
|
+
ruff format --check .
|
|
167
|
+
ruff check .
|
|
168
|
+
mypy
|
|
169
|
+
python -m pip check
|
|
170
|
+
python -m build
|
|
171
|
+
python -m twine check dist/*
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## GitHub Repository Setup
|
|
175
|
+
|
|
176
|
+
This project is prepared for the future repository `andreagemma/graph`.
|
|
177
|
+
|
|
178
|
+
1. Create the empty repository on GitHub.
|
|
179
|
+
2. Initialize the local repository if needed and push the project to `main`.
|
|
180
|
+
3. Confirm the CI workflow passes on GitHub.
|
|
181
|
+
4. Configure the PyPI Trusted Publisher for project `ga-graph`, owner
|
|
182
|
+
`andreagemma`, repository `graph`, workflow `release.yml`, and environment
|
|
183
|
+
`pypi`.
|
|
184
|
+
|
|
185
|
+
## Releases
|
|
186
|
+
|
|
187
|
+
`src/graph/_version.py` is the only version source. To publish a release:
|
|
188
|
+
|
|
189
|
+
1. Update `__version__` in `_version.py` and commit the release changes.
|
|
190
|
+
2. Push `main` and wait for CI to pass.
|
|
191
|
+
3. Run the **Create release** GitHub Actions workflow. With no override it
|
|
192
|
+
creates the `v<version>` tag, creates release notes, and dispatches the build
|
|
193
|
+
and PyPI publication workflow.
|
|
194
|
+
|
|
195
|
+
PyPI versions are immutable. Increment `_version.py` before publishing different
|
|
196
|
+
content.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
GA Graph is distributed under the MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
ga_graph-0.1.0.dist-info/licenses/LICENSE,sha256=j7vOUjtdElKsWC7AIQTwpEEe2bOMjm3F8v8bhZicToM,1078
|
|
2
|
+
graph/__init__.py,sha256=FUq_wmTdQUVmmP20zAmz_0KTcu45EO1I6gmuVqM4bKg,785
|
|
3
|
+
graph/_version.py,sha256=6NLIBvquSBZbsyYbuheCBbaDGP8kIXZNVc0n1fe757I,98
|
|
4
|
+
graph/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
graph/algorithms/TD_LBSP.py,sha256=AVMrFhHAviNBUlAsa6L4dWCvjk-pvIQ2qKL4dr32QEw,11794
|
|
6
|
+
graph/algorithms/__init__.py,sha256=0DJDv3U9Wub3qxnz-F1XldP80rcq9MQXi-s9qLvdksQ,106
|
|
7
|
+
graph/graphs/__init__.py,sha256=9s39NZ4VNKiUcmKVCj87kMbUuyHfE90kZtsF0PYRYHQ,542
|
|
8
|
+
graph/graphs/action_policy.py,sha256=5SNn1eJTcu2w7SbCzOsgGpNxOlOPM9NrwpNcMx4Df2s,705
|
|
9
|
+
graph/graphs/exceptions.py,sha256=Hc1FbqklxquqjZV9AbGzgM89b-7oeWTC5H3m9thVwZQ,916
|
|
10
|
+
graph/graphs/graph.py,sha256=qJK-H8ABkOcTseFRJaz-ck2V1hnAe-qtwB0DElBV9O4,16145
|
|
11
|
+
graph/graphs/link.py,sha256=s4Idwj2aTVGAdxEF8cVAITV7bll91jFIbaPOEVAkSMo,1483
|
|
12
|
+
graph/graphs/node.py,sha256=5dz4TCUM7oytGSs5VglVxm-w5QPDiOEJFA9QvLlFVJA,1152
|
|
13
|
+
graph/graphs/turn.py,sha256=3p8jcnmCxHkSHdO0T0Py77jHbVCpUkmkEDv0DuXCdU0,6976
|
|
14
|
+
graph/paths/__init__.py,sha256=ppAKzoV7JWUU0QeKXMVwsyNqgJiARhrEO_NeTAiXZ2c,280
|
|
15
|
+
graph/paths/k_path_container.py,sha256=s4zvBJvOWqwSIm2VkNGZeNWY0DLNy-LqTThKtDtdXuU,2849
|
|
16
|
+
graph/paths/k_path_list.py,sha256=nc9_HNAFqKWqWnXJqxV5-CrUnRUIvL_-1CqLELOcT-o,5944
|
|
17
|
+
graph/paths/path.py,sha256=8hMzb97H4dT-6htyTfVXlPNW6N5gkkJZPzBo9P5el_0,2862
|
|
18
|
+
graph/paths/path_container.py,sha256=h7vM-7ArWXqBs_V_zBEibPSAPZBKvnnJHsTe3Iia1jU,3571
|
|
19
|
+
graph/paths/path_list.py,sha256=Ir4e1BVrEhdWScqDujE4uhyJ-giQfZKO2U73deiXp38,4152
|
|
20
|
+
ga_graph-0.1.0.dist-info/METADATA,sha256=xNPa9tce-RUarmAwGxPSFThiZ37FBYdVoUyKRMo1Qk8,6604
|
|
21
|
+
ga_graph-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
22
|
+
ga_graph-0.1.0.dist-info/top_level.txt,sha256=_Z1j8Ab-1JfYNhOLenOKxKxm9WCTc_kVydsPHuFI0zk,6
|
|
23
|
+
ga_graph-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GA Graph contributors
|
|
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 @@
|
|
|
1
|
+
graph
|
graph/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .algorithms import TimeDependentLinkBasedShortestPath
|
|
3
|
+
from .graphs import (
|
|
4
|
+
ActionPolicy,
|
|
5
|
+
ExistingLinkError,
|
|
6
|
+
ExistingNodeError,
|
|
7
|
+
ExistingTurnError,
|
|
8
|
+
Graph,
|
|
9
|
+
GraphError,
|
|
10
|
+
Link,
|
|
11
|
+
MissingLinkError,
|
|
12
|
+
MissingNodeError,
|
|
13
|
+
Node,
|
|
14
|
+
Turn,
|
|
15
|
+
TurnType,
|
|
16
|
+
)
|
|
17
|
+
from .paths import KPathContainer, KPathList, Path, PathContainer, PathList
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ActionPolicy",
|
|
21
|
+
"ExistingLinkError",
|
|
22
|
+
"ExistingNodeError",
|
|
23
|
+
"ExistingTurnError",
|
|
24
|
+
"Graph",
|
|
25
|
+
"GraphError",
|
|
26
|
+
"KPathContainer",
|
|
27
|
+
"KPathList",
|
|
28
|
+
"Link",
|
|
29
|
+
"MissingLinkError",
|
|
30
|
+
"MissingNodeError",
|
|
31
|
+
"Node",
|
|
32
|
+
"Path",
|
|
33
|
+
"PathContainer",
|
|
34
|
+
"PathList",
|
|
35
|
+
"TimeDependentLinkBasedShortestPath",
|
|
36
|
+
"Turn",
|
|
37
|
+
"TurnType",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
graph/_version.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Link-based, time-dependent shortest-path routing.
|
|
2
|
+
|
|
3
|
+
The :class:`TimeDependentLinkBasedShortestPath` router is a routing-only view of
|
|
4
|
+
``Graph``. Nodes and links are converted to dense integer indices, while the
|
|
5
|
+
original hashable identifiers stay at the public API boundary.
|
|
6
|
+
|
|
7
|
+
Costs are interpreted as durations and must use the same time unit as
|
|
8
|
+
``Graph.delta_t`` and ``departure_time``. A vector cost is piecewise constant:
|
|
9
|
+
element ``k`` applies on ``[k * delta_t, (k + 1) * delta_t)`` relative to
|
|
10
|
+
``Graph.t0``.
|
|
11
|
+
|
|
12
|
+
The shortest-path method implements link-based time-dependent Dijkstra. It is
|
|
13
|
+
correct for FIFO cost functions; waiting at nodes is not modelled.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import Hashable, Iterable
|
|
20
|
+
from heapq import heappop as pop
|
|
21
|
+
from heapq import heappush as push
|
|
22
|
+
from itertools import count
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..graphs.graph import Graph
|
|
26
|
+
from ..graphs.link import Link
|
|
27
|
+
from ..graphs.node import Node
|
|
28
|
+
from ..graphs.turn import Turn
|
|
29
|
+
from ..paths import Path, PathContainer, PathList
|
|
30
|
+
|
|
31
|
+
CostProfile = tuple[float, ...]
|
|
32
|
+
NodeRow = tuple[int, Hashable, CostProfile]
|
|
33
|
+
LinkRow = tuple[int, Hashable, int, int, CostProfile]
|
|
34
|
+
TurnRow = tuple[int, Hashable, int, int, CostProfile]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TimeDependentLinkBasedShortestPath:
|
|
38
|
+
"""Compute link-based shortest paths on a time-dependent graph."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
"""Create an empty router. Use ``from_graph`` to populate it."""
|
|
42
|
+
self.delta_t: int | float = 1
|
|
43
|
+
self.total_time: int | float = 10
|
|
44
|
+
self.n_intervals: int = 0
|
|
45
|
+
self.t0: int | float = 0
|
|
46
|
+
self.mode: str | None = None
|
|
47
|
+
self.nodes: tuple[NodeRow, ...] = tuple()
|
|
48
|
+
self.links: tuple[LinkRow, ...] = tuple()
|
|
49
|
+
self.turns: dict[tuple[int, int], set[TurnRow]] = {}
|
|
50
|
+
self.turns_prohibited: set[tuple[int, int]] = set()
|
|
51
|
+
self.fws: dict[int, set[LinkRow]] = {}
|
|
52
|
+
self.links_fws: dict[int, set[LinkRow]] = {}
|
|
53
|
+
self.n_nodes: int = 0
|
|
54
|
+
self.n_links: int = 0
|
|
55
|
+
self.n_turns: int = 0
|
|
56
|
+
self._nodes_idx: dict[Hashable, int] = {}
|
|
57
|
+
self._links_idx: dict[Hashable, int] = {}
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def from_graph(
|
|
61
|
+
graph: Graph,
|
|
62
|
+
mode: str | None = None,
|
|
63
|
+
link_mode_field: str | None = None,
|
|
64
|
+
node_mode_field: str | None = None,
|
|
65
|
+
turn_mode_field: str | None = None,
|
|
66
|
+
link_cost_field: str | None = None,
|
|
67
|
+
node_cost_field: str | None = None,
|
|
68
|
+
turn_cost_field: str | None = None,
|
|
69
|
+
turn_prohibited_field: str | None = None,
|
|
70
|
+
) -> TimeDependentLinkBasedShortestPath:
|
|
71
|
+
"""Build a router from a ``Graph`` instance."""
|
|
72
|
+
router = TimeDependentLinkBasedShortestPath()
|
|
73
|
+
router.delta_t = graph.delta_t
|
|
74
|
+
router.total_time = graph.total_time
|
|
75
|
+
router.t0 = graph.t0
|
|
76
|
+
router.n_intervals = int(graph.num_intervals)
|
|
77
|
+
router.mode = mode
|
|
78
|
+
|
|
79
|
+
def has_mode(element: Node | Link | Turn, field: str | None) -> bool:
|
|
80
|
+
if mode is None or field is None:
|
|
81
|
+
return True
|
|
82
|
+
available_modes = element.get(field)
|
|
83
|
+
if available_modes is None:
|
|
84
|
+
return True
|
|
85
|
+
if isinstance(available_modes, str):
|
|
86
|
+
available_modes = {available_modes}
|
|
87
|
+
return "all" in available_modes or mode in available_modes
|
|
88
|
+
|
|
89
|
+
def expand_cost(
|
|
90
|
+
element: Node | Link | Turn,
|
|
91
|
+
field: str | None,
|
|
92
|
+
default: float | int = 0,
|
|
93
|
+
) -> CostProfile:
|
|
94
|
+
if field is None or field not in element:
|
|
95
|
+
return tuple([float(default)] * router.n_intervals)
|
|
96
|
+
|
|
97
|
+
value = element.get(field)
|
|
98
|
+
if value is None:
|
|
99
|
+
return tuple([float(default)] * router.n_intervals)
|
|
100
|
+
if isinstance(value, int | float | str):
|
|
101
|
+
return tuple([float(value)] * router.n_intervals)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
values = tuple(float(item) for item in value)
|
|
105
|
+
except TypeError:
|
|
106
|
+
return tuple([float(value)] * router.n_intervals)
|
|
107
|
+
|
|
108
|
+
if not values:
|
|
109
|
+
return tuple([float(default)] * router.n_intervals)
|
|
110
|
+
if len(values) < router.n_intervals:
|
|
111
|
+
values = values + tuple([values[-1]] * (router.n_intervals - len(values)))
|
|
112
|
+
return values[: router.n_intervals]
|
|
113
|
+
|
|
114
|
+
def is_turn_prohibited(turn: Turn) -> bool:
|
|
115
|
+
if turn_prohibited_field is None:
|
|
116
|
+
return False
|
|
117
|
+
return bool(turn.get(turn_prohibited_field, False))
|
|
118
|
+
|
|
119
|
+
node_data = (
|
|
120
|
+
(node.idx, expand_cost(node, node_cost_field))
|
|
121
|
+
for node in graph.get_all_nodes()
|
|
122
|
+
if has_mode(node, node_mode_field)
|
|
123
|
+
)
|
|
124
|
+
router.nodes = tuple((dense_id, *node) for dense_id, node in enumerate(node_data))
|
|
125
|
+
router._nodes_idx = {node[1]: dense_id for dense_id, node in enumerate(router.nodes)}
|
|
126
|
+
|
|
127
|
+
link_data = (
|
|
128
|
+
(
|
|
129
|
+
link.idx,
|
|
130
|
+
router._nodes_idx[link.i],
|
|
131
|
+
router._nodes_idx[link.j],
|
|
132
|
+
expand_cost(link, link_cost_field),
|
|
133
|
+
)
|
|
134
|
+
for link in graph.get_all_links()
|
|
135
|
+
if link.i in router._nodes_idx
|
|
136
|
+
and link.j in router._nodes_idx
|
|
137
|
+
and has_mode(link, link_mode_field)
|
|
138
|
+
)
|
|
139
|
+
router.links = tuple((dense_id, *link) for dense_id, link in enumerate(link_data))
|
|
140
|
+
router._links_idx = {link[1]: dense_id for dense_id, link in enumerate(router.links)}
|
|
141
|
+
|
|
142
|
+
turn_rows: list[TurnRow] = []
|
|
143
|
+
prohibited_pairs: set[tuple[int, int]] = set()
|
|
144
|
+
for turn in graph.get_all_turns():
|
|
145
|
+
if (
|
|
146
|
+
turn.in_link not in router._links_idx
|
|
147
|
+
or turn.out_link not in router._links_idx
|
|
148
|
+
or not has_mode(turn, turn_mode_field)
|
|
149
|
+
):
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
pair = (router._links_idx[turn.in_link], router._links_idx[turn.out_link])
|
|
153
|
+
if is_turn_prohibited(turn):
|
|
154
|
+
prohibited_pairs.add(pair)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
turn_rows.append(
|
|
158
|
+
(
|
|
159
|
+
len(turn_rows),
|
|
160
|
+
turn.idx,
|
|
161
|
+
pair[0],
|
|
162
|
+
pair[1],
|
|
163
|
+
expand_cost(turn, turn_cost_field),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
turns_by_pair: defaultdict[tuple[int, int], set[TurnRow]] = defaultdict(set)
|
|
168
|
+
for turn_row in turn_rows:
|
|
169
|
+
turns_by_pair[(turn_row[2], turn_row[3])].add(turn_row)
|
|
170
|
+
|
|
171
|
+
router.turns = dict(turns_by_pair)
|
|
172
|
+
router.turns_prohibited = prohibited_pairs
|
|
173
|
+
router.n_nodes = len(router.nodes)
|
|
174
|
+
router.n_links = len(router.links)
|
|
175
|
+
router.n_turns = len(turn_rows)
|
|
176
|
+
|
|
177
|
+
fws: defaultdict[int, set[LinkRow]] = defaultdict(set)
|
|
178
|
+
for link in router.links:
|
|
179
|
+
fws[link[2]].add(link)
|
|
180
|
+
router.fws = dict(fws)
|
|
181
|
+
|
|
182
|
+
links_fws: dict[int, set[LinkRow]] = {}
|
|
183
|
+
for link in router.links:
|
|
184
|
+
links_fws[link[0]] = set(fws.get(link[3], set())) - {link}
|
|
185
|
+
|
|
186
|
+
for incoming_link_id, outgoing_link_id in router.turns_prohibited:
|
|
187
|
+
if incoming_link_id in links_fws:
|
|
188
|
+
links_fws[incoming_link_id] = {
|
|
189
|
+
link for link in links_fws[incoming_link_id] if link[0] != outgoing_link_id
|
|
190
|
+
}
|
|
191
|
+
router.links_fws = links_fws
|
|
192
|
+
|
|
193
|
+
return router
|
|
194
|
+
|
|
195
|
+
def shortest_paths(
|
|
196
|
+
self,
|
|
197
|
+
source: Node | Hashable,
|
|
198
|
+
targets: Iterable[Hashable] | Hashable | None = None,
|
|
199
|
+
t_start: int | float = 0,
|
|
200
|
+
**kwargs: Any,
|
|
201
|
+
) -> PathContainer:
|
|
202
|
+
"""Compute shortest paths from ``source`` to one or more targets."""
|
|
203
|
+
del kwargs
|
|
204
|
+
source_label = source.idx if isinstance(source, Node) else source
|
|
205
|
+
source_id = self._nodes_idx[source_label]
|
|
206
|
+
|
|
207
|
+
target_labels = self._normalize_targets(targets)
|
|
208
|
+
target_ids = {self._nodes_idx[target] for target in target_labels}
|
|
209
|
+
residual_targets = target_ids.copy()
|
|
210
|
+
|
|
211
|
+
paths = PathList()
|
|
212
|
+
paths_links: list[list[Hashable]] = [[] for _ in self.links]
|
|
213
|
+
paths_costs = [float("inf")] * self.n_links
|
|
214
|
+
best_target_link: dict[int, int] = {}
|
|
215
|
+
|
|
216
|
+
queue: list[tuple[float, int, LinkRow]] = []
|
|
217
|
+
tie_breaker = count()
|
|
218
|
+
departure_time = self.t0 + t_start
|
|
219
|
+
|
|
220
|
+
for start_link in self.fws.get(source_id, set()):
|
|
221
|
+
start_link_id, start_link_idx, _, _, link_costs = start_link
|
|
222
|
+
cost_index = self._time_index(departure_time)
|
|
223
|
+
arrival_time = departure_time + link_costs[cost_index]
|
|
224
|
+
paths_costs[start_link_id] = arrival_time
|
|
225
|
+
paths_links[start_link_id] = [start_link_idx]
|
|
226
|
+
push(queue, (arrival_time, next(tie_breaker), start_link))
|
|
227
|
+
|
|
228
|
+
while queue:
|
|
229
|
+
current_time, _, incoming_link = pop(queue)
|
|
230
|
+
incoming_link_id, _, _, current_node, _ = incoming_link
|
|
231
|
+
|
|
232
|
+
if current_time > paths_costs[incoming_link_id]:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
if current_node in residual_targets:
|
|
236
|
+
best_target_link[current_node] = incoming_link_id
|
|
237
|
+
residual_targets.discard(current_node)
|
|
238
|
+
if not residual_targets:
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
for outgoing_link in self.links_fws.get(incoming_link_id, set()):
|
|
242
|
+
outgoing_link_id, outgoing_link_idx, _, _, outgoing_link_costs = outgoing_link
|
|
243
|
+
cost_index = self._time_index(current_time)
|
|
244
|
+
node_costs = self.nodes[current_node][2]
|
|
245
|
+
transition_cost = node_costs[cost_index]
|
|
246
|
+
|
|
247
|
+
for turn in self.turns.get((incoming_link_id, outgoing_link_id), set()):
|
|
248
|
+
transition_cost += turn[4][cost_index]
|
|
249
|
+
|
|
250
|
+
outgoing_departure_time = current_time + transition_cost
|
|
251
|
+
link_cost_index = self._time_index(outgoing_departure_time)
|
|
252
|
+
candidate_time = outgoing_departure_time + outgoing_link_costs[link_cost_index]
|
|
253
|
+
|
|
254
|
+
if candidate_time < paths_costs[outgoing_link_id]:
|
|
255
|
+
paths_costs[outgoing_link_id] = candidate_time
|
|
256
|
+
paths_links[outgoing_link_id] = paths_links[incoming_link_id] + [
|
|
257
|
+
outgoing_link_idx
|
|
258
|
+
]
|
|
259
|
+
push(queue, (candidate_time, next(tie_breaker), outgoing_link))
|
|
260
|
+
|
|
261
|
+
for target_id in target_ids:
|
|
262
|
+
last_link_id = best_target_link.get(target_id)
|
|
263
|
+
if last_link_id is None:
|
|
264
|
+
continue
|
|
265
|
+
link_ids = paths_links[last_link_id]
|
|
266
|
+
path_costs = [paths_costs[self._links_idx[link_id]] for link_id in link_ids]
|
|
267
|
+
paths.add_path(
|
|
268
|
+
Path(
|
|
269
|
+
source=source_label,
|
|
270
|
+
target=self.nodes[target_id][1],
|
|
271
|
+
t_start=t_start,
|
|
272
|
+
links=link_ids,
|
|
273
|
+
costs=path_costs,
|
|
274
|
+
mode=self.mode,
|
|
275
|
+
t_base=self.t0,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return paths
|
|
280
|
+
|
|
281
|
+
def _normalize_targets(
|
|
282
|
+
self,
|
|
283
|
+
targets: Iterable[Hashable] | Hashable | None,
|
|
284
|
+
) -> set[Hashable]:
|
|
285
|
+
if targets is None:
|
|
286
|
+
return {node[1] for node in self.nodes}
|
|
287
|
+
try:
|
|
288
|
+
if targets in self._nodes_idx:
|
|
289
|
+
return {targets}
|
|
290
|
+
except TypeError:
|
|
291
|
+
pass
|
|
292
|
+
if isinstance(targets, Iterable) and not isinstance(targets, str | bytes):
|
|
293
|
+
return set(targets)
|
|
294
|
+
return {targets}
|
|
295
|
+
|
|
296
|
+
def _time_index(self, time_value: float | int) -> int:
|
|
297
|
+
if self.n_intervals <= 0:
|
|
298
|
+
return 0
|
|
299
|
+
return max(0, min(int((time_value - self.t0) // self.delta_t), self.n_intervals - 1))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
__all__ = ["TimeDependentLinkBasedShortestPath"]
|
graph/graphs/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .action_policy import ActionPolicy
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
ExistingLinkError,
|
|
4
|
+
ExistingNodeError,
|
|
5
|
+
ExistingTurnError,
|
|
6
|
+
GraphError,
|
|
7
|
+
MissingLinkError,
|
|
8
|
+
MissingNodeError,
|
|
9
|
+
)
|
|
10
|
+
from .graph import Graph
|
|
11
|
+
from .link import Link
|
|
12
|
+
from .node import Node
|
|
13
|
+
from .turn import Turn, TurnType
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ActionPolicy",
|
|
17
|
+
"ExistingLinkError",
|
|
18
|
+
"ExistingNodeError",
|
|
19
|
+
"ExistingTurnError",
|
|
20
|
+
"Graph",
|
|
21
|
+
"GraphError",
|
|
22
|
+
"Link",
|
|
23
|
+
"MissingLinkError",
|
|
24
|
+
"MissingNodeError",
|
|
25
|
+
"Node",
|
|
26
|
+
"Turn",
|
|
27
|
+
"TurnType",
|
|
28
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ActionPolicy(str, Enum):
|
|
7
|
+
"""Behaviour for duplicate, missing, or dependent entities."""
|
|
8
|
+
|
|
9
|
+
RAISE = "raise"
|
|
10
|
+
WARN = "warn"
|
|
11
|
+
IGNORE = "ignore"
|
|
12
|
+
REPLACE = "replace"
|
|
13
|
+
SKIP = "skip"
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def parse(cls, value: ActionPolicy | str) -> ActionPolicy:
|
|
17
|
+
"""Return an ``ActionPolicy`` from an enum value or string."""
|
|
18
|
+
if isinstance(value, cls):
|
|
19
|
+
return value
|
|
20
|
+
try:
|
|
21
|
+
return cls(value)
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
msg = "policy must be 'raise', 'warn', 'ignore', 'replace', or 'skip'"
|
|
24
|
+
raise ValueError(msg) from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["ActionPolicy"]
|