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.
@@ -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
+ [![CI](https://github.com/andreagemma/graph/actions/workflows/ci.yml/badge.svg)](https://github.com/andreagemma/graph/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/ga-graph.svg)](https://pypi.org/project/ga-graph/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/ga-graph.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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,3 @@
1
+ """Package version: the single source of truth for builds and releases."""
2
+
3
+ __version__ = "0.1.0"
@@ -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"]
@@ -0,0 +1,3 @@
1
+ from .TD_LBSP import TimeDependentLinkBasedShortestPath
2
+
3
+ __all__ = ["TimeDependentLinkBasedShortestPath"]
@@ -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"]