Skip to content

Commit 47b6f79

Browse files
Implemented MinPathCover model, fixed recursion depth in antichain computation
1 parent 6502c1c commit 47b6f79

File tree

8 files changed

+553
-29
lines changed

8 files changed

+553
-29
lines changed

docs/minimum-path-cover.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Minimum Path Cover
2+
3+
## 1. Definition
4+
5+
The Minimum Path Cover problem on a directed **acyclic** graph (*DAG*) is defined as follows:
6+
7+
- **INPUT**: A directed graph $G = (V,E)$.
8+
9+
- **OUTPUT**: A minimum number $k$ of source-to-sink paths, $P_1,\dots,P_k$ such that every edge $e \in E$ appears in at least one $P_i$.
10+
11+
!!! info "Note"
12+
The graph may have more than one source or sink nodes. The solution paths are required to start in some source node, and end in some sink node.
13+
14+
## 2. Solving the problem
15+
16+
We create the graph as a [networkx DiGraph](https://networkx.org/documentation/stable/reference/classes/digraph.html). In real project, you will likely have a method that transforms your graph to a DiGraph. We also give an attribute `flow` for every edge storing its flow value.
17+
18+
``` python
19+
import flowpaths as fp
20+
import networkx as nx
21+
22+
graph = nx.DiGraph()
23+
graph.add_edge("s", "a")
24+
graph.add_edge("s", "b")
25+
graph.add_edge("a", "b")
26+
graph.add_edge("a", "c")
27+
graph.add_edge("b", "c")
28+
graph.add_edge("c", "d")
29+
graph.add_edge("c", "t")
30+
graph.add_edge("d", "t")
31+
32+
mpc_model = fp.MinPathCover(graph)
33+
mpc_model.solve()
34+
```
35+
36+
The solution of `MinPathCover` is a dictionary, with an key `'paths'` containing the solution paths:
37+
38+
``` python
39+
if mpc_model.is_solved():
40+
solution = mpc_model.get_solution()
41+
print(solution)
42+
# {'paths': [
43+
# ['s', 'b', 'c', 't'],
44+
# ['s', 'a', 'b', 'c', 'd', 't'],
45+
# ['s', 'a', 'c', 't']]}
46+
```
47+
48+
We can also support subpath constraints:
49+
50+
``` python
51+
subpath_constraints=[[("a", "c"),("c", "t")]]
52+
mpc_model_sc = fp.MinPathCover(
53+
graph,
54+
subpath_constraints=subpath_constraints,
55+
)
56+
mpc_model_sc.solve()
57+
```
58+
59+
::: flowpaths.minpathcover
60+
options:
61+
filters:
62+
- "!^__"
63+
- "!^solve_time_elapsed"
64+
65+
::: flowpaths.kpathcover
66+
options:
67+
filters:
68+
- "!^__"

examples/min_path_cover.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import flowpaths as fp
2+
import networkx as nx
3+
4+
def main():
5+
# Create a simple graph
6+
graph = nx.DiGraph()
7+
graph.add_edge("s", "a")
8+
graph.add_edge("s", "b")
9+
graph.add_edge("a", "b")
10+
graph.add_edge("a", "c")
11+
graph.add_edge("b", "c")
12+
graph.add_edge("c", "d")
13+
graph.add_edge("c", "t")
14+
graph.add_edge("d", "t")
15+
16+
mpc_model = fp.MinPathCover(graph)
17+
mpc_model.solve()
18+
19+
subpath_constraints=[[("a", "c"),("c", "t")]]
20+
21+
mpc_model_sc = fp.MinPathCover(
22+
graph,
23+
subpath_constraints=subpath_constraints,
24+
)
25+
mpc_model_sc.solve()
26+
process_solution(mpc_model_sc)
27+
28+
def process_solution(model: fp.MinFlowDecomp):
29+
if model.is_solved():
30+
print(model.get_solution())
31+
print(model.solve_statistics)
32+
else:
33+
print("Model could not be solved.")
34+
35+
if __name__ == "__main__":
36+
main()

flowpaths/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from .mingenset import MinGenSet
1111
from .minsetcover import MinSetCover
1212
from .minerrorflow import MinErrorFlow
13+
from .kpathcover import kPathCover
14+
from .minpathcover import MinPathCover
1315

1416
__all__ = [
1517
"AbstractPathModelDAG",
@@ -24,4 +26,6 @@
2426
"MinGenSet",
2527
"MinSetCover",
2628
"MinErrorFlow",
29+
"kPathCover",
30+
"MinPathCover",
2731
]

flowpaths/kpathcover.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import time
2+
import networkx as nx
3+
import flowpaths.stdigraph as stdigraph
4+
import flowpaths.utils.graphutils as gu
5+
import flowpaths.abstractpathmodeldag as pathmodel
6+
import flowpaths.utils.safetyflowdecomp as sfd
7+
import flowpaths.utils as utils
8+
9+
class kPathCover(pathmodel.AbstractPathModelDAG):
10+
def __init__(
11+
self,
12+
G: nx.DiGraph,
13+
k: int,
14+
subpath_constraints: list = [],
15+
subpath_constraints_coverage: float = 1.0,
16+
subpath_constraints_coverage_length: float = None,
17+
edge_length_attr: str = None,
18+
edges_to_ignore: list = [],
19+
optimization_options: dict = {},
20+
solver_options: dict = {},
21+
):
22+
"""
23+
This class finds, if possible, `k` paths covering the edges of a directed acyclic graph (DAG) -- and generalizations of this problem, see the parameters below.
24+
25+
Parameters
26+
----------
27+
- `G : nx.DiGraph`
28+
29+
The input directed acyclic graph, as networkx DiGraph.
30+
31+
- `k: int`
32+
33+
The number of paths to decompose in.
34+
35+
- `subpath_constraints : list`, optional
36+
37+
List of subpath constraints. Default is an empty list.
38+
Each subpath constraint is a list of edges that must be covered by some solution path, according
39+
to the `subpath_constraints_coverage` or `subpath_constraints_coverage_length` parameters (see below).
40+
41+
- `subpath_constraints_coverage : float`, optional
42+
43+
Coverage fraction of the subpath constraints that must be covered by some solution paths.
44+
45+
Defaults to `1.0` (meaning that 100% of the edges of the constraint need to be covered by some solution path). See [subpath constraints documentation](subpath-constraints.md#3-relaxing-the-constraint-coverage)
46+
47+
- `subpath_constraints_coverage_length : float`, optional
48+
49+
Coverage length of the subpath constraints. Default is `None`. If set, this overrides `subpath_constraints_coverage`,
50+
and the coverage constraint is expressed in terms of the subpath constraint length.
51+
`subpath_constraints_coverage_length` is then the fraction of the total length of the constraint (specified via `edge_length_attr`) needs to appear in some solution path.
52+
See [subpath constraints documentation](subpath-constraints.md#3-relaxing-the-constraint-coverage)
53+
54+
- `edge_length_attr : str`, optional
55+
56+
Attribute name for edge lengths. Default is `None`.
57+
58+
- `edges_to_ignore : list`, optional
59+
60+
List of edges to ignore when adding constrains on flow explanation by the weighted paths.
61+
Default is an empty list. See [ignoring edges documentation](ignoring-edges.md)
62+
63+
- `optimization_options : dict`, optional
64+
65+
Dictionary with the optimization options. Default is `None`. See [optimization options documentation](solver-options-optimizations.md).
66+
67+
- `solver_options : dict`, optional
68+
69+
Dictionary with the solver options. Default is `None`. See [solver options documentation](solver-options-optimizations.md).
70+
71+
"""
72+
73+
self.G = stdigraph.stDiGraph(G)
74+
self.edges_to_ignore = self.G.source_sink_edges.union(edges_to_ignore)
75+
76+
self.k = k
77+
self.subpath_constraints = subpath_constraints
78+
self.subpath_constraints_coverage = subpath_constraints_coverage
79+
self.subpath_constraints_coverage_length = subpath_constraints_coverage_length
80+
self.edge_length_attr = edge_length_attr
81+
82+
self.__solution = None
83+
self.__lowerbound_k = None
84+
85+
self.solve_statistics = {}
86+
self.optimization_options = optimization_options
87+
self.optimization_options["trusted_edges_for_safety"] = set(e for e in self.G.edges() if e not in self.edges_to_ignore)
88+
89+
# Call the constructor of the parent class AbstractPathModelDAG
90+
super().__init__(
91+
G=self.G,
92+
k=self.k,
93+
subpath_constraints=self.subpath_constraints,
94+
subpath_constraints_coverage=self.subpath_constraints_coverage,
95+
subpath_constraints_coverage_length=self.subpath_constraints_coverage_length,
96+
edge_length_attr=self.edge_length_attr,
97+
optimization_options=self.optimization_options,
98+
solver_options=solver_options,
99+
solve_statistics=self.solve_statistics,
100+
)
101+
102+
# This method is called from the super class AbstractPathModelDAG
103+
self.create_solver_and_paths()
104+
105+
# This method is called from the current class to encode the path cover
106+
self.__encode_path_cover()
107+
108+
utils.logger.info(f"{__name__}: initialized with graph id = {utils.fpid(G)}, k = {self.k}")
109+
110+
def __encode_path_cover(self):
111+
112+
# We encode that for each edge (u,v), the sum of the weights of the paths going through the edge is equal to the flow value of the edge.
113+
for u, v, data in self.G.edges(data=True):
114+
if (u, v) in self.edges_to_ignore:
115+
continue
116+
117+
# We require that self.edge_vars[(u, v, i)] is 1 for at least one i
118+
self.solver.add_constraint(
119+
self.solver.quicksum(
120+
self.edge_vars[(u, v, i)]
121+
for i in range(self.k)
122+
) >= 1,
123+
name=f"cover_u={u}_v={v}",
124+
)
125+
126+
def get_solution(self):
127+
"""
128+
Retrieves the solution for the k-path cover problem.
129+
130+
Returns
131+
-------
132+
- `solution: dict`
133+
134+
A dictionary containing the solution paths, under key `"paths"`.
135+
136+
Raises
137+
------
138+
- `exception` If model is not solved.
139+
"""
140+
141+
if self.__solution is None:
142+
self.check_is_solved()
143+
self.__solution = {
144+
"paths": self.get_solution_paths(),
145+
}
146+
147+
return self.__solution
148+
149+
def is_valid_solution(self, tolerance=0.001):
150+
"""
151+
Checks if the solution is valid, meaning it covers all required edges.
152+
153+
Raises
154+
------
155+
- ValueError: If the solution is not available (i.e., self.solution is None).
156+
157+
Returns
158+
-------
159+
- bool: True if the solution is valid, False otherwise.
160+
161+
Notes
162+
-------
163+
- get_solution() must be called before this method.
164+
"""
165+
166+
if self.__solution is None:
167+
utils.logger.error(f"{__name__}: Solution is not available. Call get_solution() first.")
168+
raise ValueError("Solution is not available. Call get_solution() first.")
169+
170+
solution_paths = self.__solution["paths"]
171+
solution_paths_of_edges = [
172+
[(path[i], path[i + 1]) for i in range(len(path) - 1)]
173+
for path in solution_paths
174+
]
175+
176+
covered_by_paths = {(u, v): 0 for (u, v) in self.G.edges()}
177+
for path in solution_paths_of_edges:
178+
for e in path:
179+
covered_by_paths[e] += 1
180+
181+
for u, v in self.G.edges():
182+
if (u,v) not in self.edges_to_ignore:
183+
if covered_by_paths[(u, v)] == 0:
184+
return False
185+
186+
return True
187+
188+
def get_objective_value(self):
189+
190+
self.check_is_solved()
191+
192+
return self.k
193+
194+
def get_lowerbound_k(self):
195+
196+
if self.__lowerbound_k is None:
197+
self.__lowerbound_k = self.G.get_width(edges_to_ignore=self.edges_to_ignore)
198+
199+
return self.__lowerbound_k

flowpaths/minflowdecomp.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import copy
1111
import math
1212

13-
class MinFlowDecomp(pathmodel.AbstractPathModelDAG): # Note that we inherit from AbstractPathModelDAG to be able to use this class to also compute safe paths,
13+
class MinFlowDecomp(pathmodel.AbstractPathModelDAG): # Note that we inherit from AbstractPathModelDAG to be able to use this class to also compute safe paths.
1414
"""
1515
A class to decompose a network flow if a directed acyclic graph into a minimum number of weighted paths.
1616
"""
@@ -138,9 +138,9 @@ def __init__(
138138

139139
def solve(self) -> bool:
140140
"""
141-
Attempts to solve the flow distribution problem using a model with varying number of paths.
141+
Attempts to solve the flow decomposition problem using a model with varying number of paths.
142142
143-
This method iterates over a range of possible path counts, creating and solving a flow decomposition model for each count.
143+
This method iterates over a range of possible path numbers, creating and solving a flow decomposition model for each count.
144144
If a solution is found, it stores the solution and relevant statistics, and returns True. If no solution is found after
145145
iterating through all possible path counts, it returns False.
146146

0 commit comments

Comments
 (0)