steinerpy 0.1.1__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) 2025 Berend
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,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: steinerpy
3
+ Version: 0.1.1
4
+ Summary: Package to solve Steiner Tree and Steiner Forest Problems with the HiGHS solver
5
+ Project-URL: Homepage, https://github.com/berendmarkhorst/SteinerPy
6
+ Project-URL: Repository, https://github.com/berendmarkhorst/SteinerPy
7
+ Project-URL: Issues, https://github.com/berendmarkhorst/SteinerPy/issues
8
+ Project-URL: Documentation, https://github.com/berendmarkhorst/SteinerPy
9
+ Project-URL: Changelog, https://github.com/berendmarkhorst/SteinerPy/releases
10
+ Author: Berend Markhorst, Joost Berkhout, Alessandro Zocca, Jeroen Pruyn, Rob van der Mei
11
+ Maintainer-email: Berend Markhorst <your.email@example.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: forest,graph,highs,networkx,optimization,steiner,tree
15
+ Classifier: Development Status :: 3 - Alpha
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.8
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Topic :: Scientific/Engineering
26
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
27
+ Requires-Python: >=3.8
28
+ Requires-Dist: highspy
29
+ Requires-Dist: networkx
30
+ Provides-Extra: dev
31
+ Requires-Dist: black; extra == 'dev'
32
+ Requires-Dist: flake8; extra == 'dev'
33
+ Requires-Dist: mypy; extra == 'dev'
34
+ Requires-Dist: pytest; extra == 'dev'
35
+ Requires-Dist: pytest-cov; extra == 'dev'
36
+ Provides-Extra: docs
37
+ Requires-Dist: sphinx; extra == 'docs'
38
+ Requires-Dist: sphinx-rtd-theme; extra == 'docs'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # SteinerPy
42
+
43
+ [![PyPI version](https://badge.fury.io/py/steinerpy.svg)](https://badge.fury.io/py/steinerpy)
44
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
45
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
46
+
47
+ A Python package for solving Steiner Tree and Steiner Forest Problems using the HiGHS solver and NetworkX graphs.
48
+
49
+ ## Installation
50
+
51
+ Install SteinerPy using pip:
52
+
53
+ ```bash
54
+ pip install steinerpy
55
+ ```
56
+
57
+ Or using uv:
58
+
59
+ ```bash
60
+ uv add steinerpy
61
+ ```
62
+
63
+ ## Quick Start
64
+
65
+ ```python
66
+ import networkx as nx
67
+ from steinerpy import SteinerProblem
68
+
69
+ # Create a graph
70
+ G = nx.Graph()
71
+ G.add_edge('A', 'B', weight=1)
72
+ G.add_edge('B', 'C', weight=2)
73
+ G.add_edge('C', 'D', weight=1)
74
+
75
+ # Define terminal groups
76
+ terminal_groups = [['A', 'D']]
77
+
78
+ # Solve the Steiner problem
79
+ problem = SteinerProblem(G, terminal_groups)
80
+ solution = problem.get_solution()
81
+
82
+ print(f"Optimal cost: {solution.objective}")
83
+ print(f"Selected edges: {solution.selected_edges}")
84
+ ```
85
+
86
+ ## Usage Examples
87
+
88
+ See the `example.ipynb` notebook for detailed usage examples.
89
+
90
+ ## Dependencies
91
+
92
+ - `networkx`: For graph representation and manipulation
93
+ - `highspy`: For optimization solving
94
+
95
+ If you use this package in your research, please cite:
96
+
97
+ ```
98
+ @article{markhorst2025future,
99
+ title={Future-proof ship pipe routing: Navigating the energy transition},
100
+ author={Markhorst, Berend and Berkhout, Joost and Zocca, Alessandro and Pruyn, Jeroen and van der Mei, Rob},
101
+ journal={Ocean Engineering},
102
+ volume={319},
103
+ pages={120113},
104
+ year={2025},
105
+ publisher={Elsevier}
106
+ }
107
+ ```
@@ -0,0 +1,67 @@
1
+ # SteinerPy
2
+
3
+ [![PyPI version](https://badge.fury.io/py/steinerpy.svg)](https://badge.fury.io/py/steinerpy)
4
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A Python package for solving Steiner Tree and Steiner Forest Problems using the HiGHS solver and NetworkX graphs.
8
+
9
+ ## Installation
10
+
11
+ Install SteinerPy using pip:
12
+
13
+ ```bash
14
+ pip install steinerpy
15
+ ```
16
+
17
+ Or using uv:
18
+
19
+ ```bash
20
+ uv add steinerpy
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```python
26
+ import networkx as nx
27
+ from steinerpy import SteinerProblem
28
+
29
+ # Create a graph
30
+ G = nx.Graph()
31
+ G.add_edge('A', 'B', weight=1)
32
+ G.add_edge('B', 'C', weight=2)
33
+ G.add_edge('C', 'D', weight=1)
34
+
35
+ # Define terminal groups
36
+ terminal_groups = [['A', 'D']]
37
+
38
+ # Solve the Steiner problem
39
+ problem = SteinerProblem(G, terminal_groups)
40
+ solution = problem.get_solution()
41
+
42
+ print(f"Optimal cost: {solution.objective}")
43
+ print(f"Selected edges: {solution.selected_edges}")
44
+ ```
45
+
46
+ ## Usage Examples
47
+
48
+ See the `example.ipynb` notebook for detailed usage examples.
49
+
50
+ ## Dependencies
51
+
52
+ - `networkx`: For graph representation and manipulation
53
+ - `highspy`: For optimization solving
54
+
55
+ If you use this package in your research, please cite:
56
+
57
+ ```
58
+ @article{markhorst2025future,
59
+ title={Future-proof ship pipe routing: Navigating the energy transition},
60
+ author={Markhorst, Berend and Berkhout, Joost and Zocca, Alessandro and Pruyn, Jeroen and van der Mei, Rob},
61
+ journal={Ocean Engineering},
62
+ volume={319},
63
+ pages={120113},
64
+ year={2025},
65
+ publisher={Elsevier}
66
+ }
67
+ ```
@@ -0,0 +1,325 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "c0ac72cda79156ac",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Example of solving a Steiner Tree/Forest Problem using HighsPy\n",
9
+ "This example demonstrates how to use the HighsPy library to solve a Steiner Tree Problem on a simple graph. We will create a graph with nodes and edges, define the terminals we want to connect, and then use the `SteinerProblem` class to find the optimal solution."
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": null,
15
+ "id": "initial_id",
16
+ "metadata": {
17
+ "ExecuteTime": {
18
+ "end_time": "2025-11-11T11:13:52.132433Z",
19
+ "start_time": "2025-11-11T11:13:51.793202Z"
20
+ }
21
+ },
22
+ "outputs": [],
23
+ "source": [
24
+ "import networkx as nx\n",
25
+ "from steinerpy import SteinerProblem"
26
+ ]
27
+ },
28
+ {
29
+ "cell_type": "markdown",
30
+ "id": "2bd47e809382c559",
31
+ "metadata": {},
32
+ "source": [
33
+ "We make a simple example with four nodes and four edges. We want to connect A, B, and D. The optimal solution is AC, BC, and CD."
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "code",
38
+ "execution_count": 2,
39
+ "id": "a5981e94e5a3231f",
40
+ "metadata": {
41
+ "ExecuteTime": {
42
+ "end_time": "2025-11-11T11:13:52.222502Z",
43
+ "start_time": "2025-11-11T11:13:52.220962Z"
44
+ }
45
+ },
46
+ "outputs": [],
47
+ "source": [
48
+ "graph = nx.Graph()\n",
49
+ "edges = [(\"A\", \"C\"), (\"A\", \"D\"), (\"B\", \"C\"), (\"C\", \"D\")]\n",
50
+ "weights = [1, 10, 1, 1]\n",
51
+ "\n",
52
+ "for i, edge in enumerate(edges):\n",
53
+ " graph.add_edge(edge[0], edge[1])\n",
54
+ " graph.edges[edge][f\"weight\"] = weights[i]"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "markdown",
59
+ "id": "f856a6c8caf083cc",
60
+ "metadata": {},
61
+ "source": [
62
+ "We now create a `SteinerProblem` instance with the graph and the terminals we want to connect. We then call the `get_solution` method to solve the problem, specifying a time limit of 300 seconds."
63
+ ]
64
+ },
65
+ {
66
+ "cell_type": "code",
67
+ "execution_count": 3,
68
+ "id": "e456f414e3e492ef",
69
+ "metadata": {
70
+ "ExecuteTime": {
71
+ "end_time": "2025-11-11T11:13:52.241085Z",
72
+ "start_time": "2025-11-11T11:13:52.230882Z"
73
+ }
74
+ },
75
+ "outputs": [
76
+ {
77
+ "name": "stderr",
78
+ "output_type": "stream",
79
+ "text": [
80
+ "INFO:root:Building the model.\n",
81
+ "INFO:root:Model built in 0.00 seconds.\n",
82
+ "INFO:root:Started with running the model...\n",
83
+ "INFO:root:Runtime: 0.01 seconds\n"
84
+ ]
85
+ },
86
+ {
87
+ "name": "stdout",
88
+ "output_type": "stream",
89
+ "text": [
90
+ "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n",
91
+ "MIP has 46 rows; 37 cols; 124 nonzeros; 21 integer variables (21 binary)\n",
92
+ "Coefficient ranges:\n",
93
+ " Matrix [1e+00, 1e+00]\n",
94
+ " Cost [1e+00, 1e+01]\n",
95
+ " Bound [1e+00, 1e+00]\n",
96
+ " RHS [1e+00, 1e+00]\n",
97
+ "Presolving model\n",
98
+ "30 rows, 23 cols, 78 nonzeros 0s\n",
99
+ "20 rows, 16 cols, 54 nonzeros 0s\n",
100
+ "11 rows, 7 cols, 29 nonzeros 0s\n",
101
+ "9 rows, 6 cols, 22 nonzeros 0s\n",
102
+ "5 rows, 5 cols, 12 nonzeros 0s\n",
103
+ "4 rows, 4 cols, 10 nonzeros 0s\n",
104
+ "Presolve reductions: rows 4(-42); columns 4(-33); nonzeros 10(-114) \n",
105
+ "Objective function is integral with scale 1\n",
106
+ "\n",
107
+ "Solving MIP model with:\n",
108
+ " 4 rows\n",
109
+ " 4 cols (4 binary, 0 integer, 0 implied int., 0 continuous, 0 domain fixed)\n",
110
+ " 10 nonzeros\n",
111
+ "\n",
112
+ "Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;\n",
113
+ " I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;\n",
114
+ " S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;\n",
115
+ " Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero\n",
116
+ "\n",
117
+ " Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work \n",
118
+ "Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time\n",
119
+ "\n",
120
+ " u 0 0 0 100.00% -inf 3 Large 0 0 0 0 0.0s\n",
121
+ " 1 0 1 100.00% 3 3 0.00% 0 0 0 0 0.0s\n",
122
+ "\n",
123
+ "Solving report\n",
124
+ " Status Optimal\n",
125
+ " Primal bound 3\n",
126
+ " Dual bound 3\n",
127
+ " Gap 0% (tolerance: 0.01%)\n",
128
+ " P-D integral 0\n",
129
+ " Solution status feasible\n",
130
+ " 3 (objective)\n",
131
+ " 0 (bound viol.)\n",
132
+ " 0 (int. viol.)\n",
133
+ " 0 (row viol.)\n",
134
+ " Timing 0.01\n",
135
+ " Max sub-MIP depth 0\n",
136
+ " Nodes 1\n",
137
+ " Repair LPs 0\n",
138
+ " LP iterations 0\n"
139
+ ]
140
+ }
141
+ ],
142
+ "source": [
143
+ "solution = SteinerProblem(graph, [[\"A\", \"B\", \"D\"]]).get_solution(time_limit=300)"
144
+ ]
145
+ },
146
+ {
147
+ "cell_type": "markdown",
148
+ "id": "98319bc0466946d4",
149
+ "metadata": {},
150
+ "source": [
151
+ "We can now access the selected edges in the optimal solution, as well as the optimality gap, runtime, and objective value."
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "code",
156
+ "execution_count": 4,
157
+ "id": "7f491b1adbb517f0",
158
+ "metadata": {
159
+ "ExecuteTime": {
160
+ "end_time": "2025-11-11T11:13:52.251404Z",
161
+ "start_time": "2025-11-11T11:13:52.248118Z"
162
+ }
163
+ },
164
+ "outputs": [
165
+ {
166
+ "data": {
167
+ "text/plain": [
168
+ "[('A', 'C'), ('C', 'B'), ('C', 'D')]"
169
+ ]
170
+ },
171
+ "execution_count": 4,
172
+ "metadata": {},
173
+ "output_type": "execute_result"
174
+ }
175
+ ],
176
+ "source": [
177
+ "solution.selected_edges"
178
+ ]
179
+ },
180
+ {
181
+ "cell_type": "code",
182
+ "execution_count": 5,
183
+ "id": "f05907e2ea4b70fe",
184
+ "metadata": {
185
+ "ExecuteTime": {
186
+ "end_time": "2025-11-11T11:13:52.261696Z",
187
+ "start_time": "2025-11-11T11:13:52.259726Z"
188
+ }
189
+ },
190
+ "outputs": [
191
+ {
192
+ "name": "stdout",
193
+ "output_type": "stream",
194
+ "text": [
195
+ "Optimality gap: 0.00\n",
196
+ "Runtime: 0.01 sec\n",
197
+ "Objective: 3.00\n"
198
+ ]
199
+ }
200
+ ],
201
+ "source": [
202
+ "print(f\"Optimality gap: {solution.gap:.2f}\")\n",
203
+ "print(f\"Runtime: {solution.runtime:.2f} sec\")\n",
204
+ "print(f\"Objective: {solution.objective:.2f}\")"
205
+ ]
206
+ },
207
+ {
208
+ "cell_type": "markdown",
209
+ "id": "ad25f45170b7a861",
210
+ "metadata": {},
211
+ "source": [
212
+ "In case you're interested in Steiner Forest Problems, you can define multiple sets of terminals to connect. For example, we can connect A and B in one set and C and D in another set."
213
+ ]
214
+ },
215
+ {
216
+ "cell_type": "code",
217
+ "execution_count": 7,
218
+ "id": "ca5995b84a21a188",
219
+ "metadata": {
220
+ "ExecuteTime": {
221
+ "end_time": "2025-11-11T11:18:35.817784Z",
222
+ "start_time": "2025-11-11T11:18:35.811777Z"
223
+ }
224
+ },
225
+ "outputs": [
226
+ {
227
+ "name": "stderr",
228
+ "output_type": "stream",
229
+ "text": [
230
+ "INFO:root:Building the model.\n",
231
+ "INFO:root:Model built in 0.00 seconds.\n",
232
+ "INFO:root:Started with running the model...\n",
233
+ "INFO:root:Runtime: 0.00 seconds\n"
234
+ ]
235
+ },
236
+ {
237
+ "name": "stdout",
238
+ "output_type": "stream",
239
+ "text": [
240
+ "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n",
241
+ "MIP has 86 rows; 63 cols; 224 nonzeros; 31 integer variables (31 binary)\n",
242
+ "Coefficient ranges:\n",
243
+ " Matrix [1e+00, 1e+00]\n",
244
+ " Cost [1e+00, 1e+01]\n",
245
+ " Bound [1e+00, 1e+00]\n",
246
+ " RHS [1e+00, 1e+00]\n",
247
+ "Presolving model\n",
248
+ "50 rows, 37 cols, 121 nonzeros 0s\n",
249
+ "29 rows, 21 cols, 76 nonzeros 0s\n",
250
+ "22 rows, 15 cols, 55 nonzeros 0s\n",
251
+ "18 rows, 11 cols, 43 nonzeros 0s\n",
252
+ "14 rows, 10 cols, 32 nonzeros 0s\n",
253
+ "9 rows, 6 cols, 19 nonzeros 0s\n",
254
+ "6 rows, 4 cols, 13 nonzeros 0s\n",
255
+ "4 rows, 3 cols, 9 nonzeros 0s\n",
256
+ "1 rows, 2 cols, 2 nonzeros 0s\n",
257
+ "0 rows, 1 cols, 0 nonzeros 0s\n",
258
+ "0 rows, 0 cols, 0 nonzeros 0s\n",
259
+ "Presolve reductions: rows 0(-86); columns 0(-63); nonzeros 0(-224) - Reduced to empty\n",
260
+ "Presolve: Optimal\n",
261
+ "\n",
262
+ "Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;\n",
263
+ " I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;\n",
264
+ " S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;\n",
265
+ " Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero\n",
266
+ "\n",
267
+ " Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work \n",
268
+ "Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time\n",
269
+ "\n",
270
+ " 0 0 0 0.00% 3 3 0.00% 0 0 0 0 0.0s\n",
271
+ "\n",
272
+ "Solving report\n",
273
+ " Status Optimal\n",
274
+ " Primal bound 3\n",
275
+ " Dual bound 3\n",
276
+ " Gap 0% (tolerance: 0.01%)\n",
277
+ " P-D integral 0\n",
278
+ " Solution status feasible\n",
279
+ " 3 (objective)\n",
280
+ " 0 (bound viol.)\n",
281
+ " 0 (int. viol.)\n",
282
+ " 0 (row viol.)\n",
283
+ " Timing 0.00\n",
284
+ " Max sub-MIP depth 0\n",
285
+ " Nodes 0\n",
286
+ " Repair LPs 0\n",
287
+ " LP iterations 0\n"
288
+ ]
289
+ }
290
+ ],
291
+ "source": [
292
+ "solution = SteinerProblem(graph, [[\"A\", \"B\"], [\"C\", \"D\"]]).get_solution(time_limit=300)\n"
293
+ ]
294
+ },
295
+ {
296
+ "cell_type": "code",
297
+ "execution_count": null,
298
+ "id": "4284e34a175c3289",
299
+ "metadata": {},
300
+ "outputs": [],
301
+ "source": []
302
+ }
303
+ ],
304
+ "metadata": {
305
+ "kernelspec": {
306
+ "display_name": "Python 3",
307
+ "language": "python",
308
+ "name": "python3"
309
+ },
310
+ "language_info": {
311
+ "codemirror_mode": {
312
+ "name": "ipython",
313
+ "version": 2
314
+ },
315
+ "file_extension": ".py",
316
+ "mimetype": "text/x-python",
317
+ "name": "python",
318
+ "nbconvert_exporter": "python",
319
+ "pygments_lexer": "ipython2",
320
+ "version": "2.7.6"
321
+ }
322
+ },
323
+ "nbformat": 4,
324
+ "nbformat_minor": 5
325
+ }
@@ -0,0 +1,99 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "steinerpy"
7
+ version = "0.1.1"
8
+ description = "Package to solve Steiner Tree and Steiner Forest Problems with the HiGHS solver"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "Berend Markhorst"},
13
+ {name = "Joost Berkhout"},
14
+ {name = "Alessandro Zocca"},
15
+ {name = "Jeroen Pruyn"},
16
+ {name = "Rob van der Mei"},
17
+ ]
18
+ maintainers = [
19
+ {name = "Berend Markhorst", email = "your.email@example.com"}, # Replace with actual email
20
+ ]
21
+ keywords = ["steiner", "tree", "forest", "optimization", "graph", "networkx", "highs"]
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Intended Audience :: Developers",
25
+ "Intended Audience :: Science/Research",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.8",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Topic :: Scientific/Engineering",
34
+ "Topic :: Scientific/Engineering :: Mathematics",
35
+ ]
36
+ requires-python = ">=3.8"
37
+ dependencies = [
38
+ "networkx",
39
+ "highspy",
40
+ ]
41
+
42
+ [project.optional-dependencies]
43
+ dev = [
44
+ "pytest",
45
+ "pytest-cov",
46
+ "black",
47
+ "flake8",
48
+ "mypy",
49
+ ]
50
+ docs = [
51
+ "sphinx",
52
+ "sphinx-rtd-theme",
53
+ ]
54
+
55
+ [project.urls]
56
+ Homepage = "https://github.com/berendmarkhorst/SteinerPy"
57
+ Repository = "https://github.com/berendmarkhorst/SteinerPy"
58
+ Issues = "https://github.com/berendmarkhorst/SteinerPy/issues"
59
+ Documentation = "https://github.com/berendmarkhorst/SteinerPy"
60
+ Changelog = "https://github.com/berendmarkhorst/SteinerPy/releases"
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["steinerpy"]
64
+
65
+ [tool.hatch.build.targets.sdist]
66
+ include = [
67
+ "/steinerpy",
68
+ "/README.md",
69
+ "/LICENSE",
70
+ "/requirements.txt",
71
+ "/example.ipynb",
72
+ ]
73
+
74
+ [tool.black]
75
+ line-length = 88
76
+ target-version = ['py38']
77
+ include = '\.pyi?$'
78
+ extend-exclude = '''
79
+ /(
80
+ # directories
81
+ \.eggs
82
+ | \.git
83
+ | \.hg
84
+ | \.mypy_cache
85
+ | \.tox
86
+ | \.venv
87
+ | _build
88
+ | buck-out
89
+ | build
90
+ | dist
91
+ )/
92
+ '''
93
+
94
+ [tool.pytest.ini_options]
95
+ testpaths = ["tests"]
96
+ python_files = "test_*.py"
97
+ python_classes = "Test*"
98
+ python_functions = "test_*"
99
+ addopts = "-v --cov=src --cov-report=html --cov-report=term-missing"
@@ -0,0 +1,2 @@
1
+ networkx
2
+ highspy
@@ -0,0 +1,14 @@
1
+ """
2
+ SteinerPy: A Python package for solving Steiner Tree and Steiner Forest Problems.
3
+
4
+ This package provides tools to solve Steiner Tree and Steiner Forest problems
5
+ using the HiGHS solver with NetworkX graphs.
6
+ """
7
+
8
+ from .objects import SteinerProblem, Solution
9
+ from .mathematical_model import build_model, run_model
10
+ from ._version import __version__
11
+ __author__ = "Berend Markhorst, Joost Berkhout, Alessandro Zocca, Jeroen Pruyn, Rob van der Mei"
12
+ __email__ = "berend.markhorst@cwi.nl"
13
+
14
+ __all__ = ["SteinerProblem", "Solution", "build_model", "run_model"]
@@ -0,0 +1,3 @@
1
+ """Version information for SteinerPy."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,244 @@
1
+ import highspy as hp
2
+ import logging
3
+ import time
4
+
5
+ # Configure logging
6
+ logging.basicConfig(level=logging.INFO)
7
+
8
+ def make_model(time_limit: float, logfile: str = "") -> hp.HighsModel:
9
+ """
10
+ Creates a HiGHS model with the given time limit and logfile.
11
+ :param time_limit: time limit in seconds for the HiGHS model.
12
+ :param logfile: path to logfile.
13
+ :return: HiGHS model.
14
+ """
15
+ # Create model
16
+ model = hp.Highs()
17
+ model.setOptionValue("time_limit", time_limit)
18
+ model.setOptionValue("output_flag", False) # Disable/enable console output
19
+
20
+ # Clear the logfile and start logging
21
+ if logfile != "":
22
+ with open(logfile, "w") as _:
23
+ pass
24
+ model.setOptionValue("log_file", logfile)
25
+
26
+ return model
27
+
28
+
29
+ def get_terminals(terminal_group: list[list]) -> list:
30
+ """
31
+ Turns a nested list of terminals into a list of terminals.
32
+ :param terminal_group: nested list of terminals.
33
+ :return: list of terminals.
34
+ """
35
+ return [t for group in terminal_group for t in group]
36
+
37
+ def terminal_groups_without_root(terminal_group: list[list], roots: list, group_index: int) -> set:
38
+ """
39
+ Get terminal groups until index k without kth root.
40
+ :param terminal_group: nested list of terminals.
41
+ :param roots: list of roots.
42
+ :param group_index: index of the terminal group.
43
+ :return: subset of terminal groups from index k to K.
44
+ """
45
+ if len(terminal_group[0]) > 0:
46
+ return set(get_terminals(terminal_group[group_index:])) - set([roots[group_index]])
47
+ else:
48
+ return set()
49
+
50
+ def get_terminal_groups_until_k(terminal_groups: list[list], group_index: int) -> set:
51
+ """
52
+ Get terminal groups until index k.
53
+ :param terminal_groups: nested list of terminals.
54
+ :param group_index: index of the terminal group.
55
+ :return: subset of terminal groups up till index k.
56
+ """
57
+ return set(get_terminals(terminal_groups[:group_index]))
58
+
59
+ def add_directed_constraints(model: hp.HighsModel, steiner_problem: 'SteinerProblem') -> tuple[hp.HighsModel, dict[hp.HighsVarType]]:
60
+ """
61
+ Adds DO-D constraints to the model (see Markhorst et al. 2025)
62
+ :param model: HiGHS model.
63
+ :param steiner_problem: AutomatedPipeRouting-object.
64
+ :return: HiGHS model with DO-D constraints and decision variables.
65
+ """
66
+ # Sets
67
+ group_indices = range(len(steiner_problem.terminal_groups))
68
+ k_indices = [(k, l) for k in group_indices for l in group_indices if l >= k]
69
+
70
+ # Decision variables
71
+ x = {e: model.addVariable(0, 1, name=f"x[{e}]") for e in steiner_problem.edges}
72
+ y1 = {a: model.addVariable(0, 1, name=f"y1[{a}]") for a in steiner_problem.arcs}
73
+ y2 = {(group_id, a): model.addVariable(0, 1, name=f"y2[{group_id},{a}]") for group_id in group_indices
74
+ for a in steiner_problem.arcs}
75
+ z = {(k, l): model.addVariable(0, 1, name=f"z[{k},{l}]") for k, l in k_indices}
76
+
77
+ for col in range(model.getNumCol()):
78
+ model.changeColIntegrality(col, hp.HighsVarType.kInteger)
79
+
80
+ # Constraint 1: connection between y2 and y1
81
+ for group_id in group_indices:
82
+ for a in steiner_problem.arcs:
83
+ model.addConstr(y2[group_id, a] <= y1[a])
84
+
85
+ # Constraint 2: indegree of each vertex cannot exceed 1
86
+ for v in steiner_problem.nodes:
87
+ lhs = sum(y1[(u, w)] for u, w in steiner_problem.arcs if v == w)
88
+ model.addConstr(lhs <= 1)
89
+
90
+ # Constraint 3: connection between y1 and x
91
+ for u, v in steiner_problem.edges:
92
+ model.addConstr(y1[(u, v)] + y1[(v, u)] <= x[(u, v)])
93
+
94
+ # Constraint 4: enforce terminal group rooted at one root
95
+ for group_id_k in group_indices:
96
+ model.addConstr(sum(z[group_id_l, group_id_k] for group_id_l in group_indices
97
+ if group_id_l <= group_id_k) == 1)
98
+
99
+ # Constraint 5: enforce one root per arborescence
100
+ for group_id_k in group_indices:
101
+ for group_id_l in group_indices:
102
+ if group_id_l > group_id_k:
103
+ model.addConstr(z[group_id_k, group_id_k] >= z[group_id_k, group_id_l])
104
+
105
+ # Constraint 6: terminals in T^{1···k−1} cannot attach to root r k
106
+ for group_id_k in group_indices:
107
+ for t in get_terminal_groups_until_k(steiner_problem.terminal_groups, group_id_k):
108
+ lhs = sum(y2[group_id_k, a] for a in steiner_problem.arcs if a[1] == t)
109
+ model.addConstr(lhs == 0)
110
+
111
+ # Constraint 7: indegree at most outdegree for Steiner points
112
+ for v in steiner_problem.steiner_points:
113
+ model.addConstr(sum(y1[(a[0], a[1])] for a in steiner_problem.arcs if a[1] == v) <=
114
+ sum(y1[(a[0], a[1])] for a in steiner_problem.arcs if a[0] == v))
115
+
116
+ # Constraint 8: indegree at most outdegree per terminal group
117
+ for group_id_k in group_indices:
118
+ remaining_vertices = set(steiner_problem.nodes) - set(terminal_groups_without_root(steiner_problem.terminal_groups, steiner_problem.roots, group_id_k))
119
+ for v in remaining_vertices:
120
+ model.addConstr(sum(y2[group_id_k, (a[0], v)] for a in steiner_problem.arcs if a[1] == v) <=
121
+ sum(y2[group_id_k, (v, a[1])] for a in steiner_problem.arcs if a[0] == v))
122
+
123
+ # Constraint 9: connect y2 and z
124
+ for group_id_k in group_indices:
125
+ for group_id_l in group_indices:
126
+ if group_id_l > group_id_k:
127
+ model.addConstr(sum(y2[group_id_k, a] for a in steiner_problem.arcs if a[1] == steiner_problem.roots[group_id_l]) <= z[group_id_k, group_id_l])
128
+
129
+ return model, x, y1, y2, z
130
+
131
+
132
+ def demand_and_supply_directed(steiner_problem: 'SteinerProblem', group_id_k: int, t: tuple, v: tuple, z: hp.HighsVarType) -> hp.HighsVarType | int:
133
+ """
134
+ Calculate the demand and supply for a directed model.
135
+ :param cc_k: The current connected component.
136
+ :param t: A terminal represented as a tuple of integers.
137
+ :param v: A vertex represented as a tuple of integers.
138
+ :param z: The decision variable z.
139
+ :return: The value of z if the vertex is the root, -z if the vertex is a terminal, and 0 otherwise.
140
+ """
141
+
142
+ # We assume terminals are disjoint from each other
143
+ group_id_l = [group_id for group_id, group in enumerate(steiner_problem.terminal_groups) if t in group][0]
144
+
145
+ if v == steiner_problem.roots[group_id_k]:
146
+ return z[(group_id_k, group_id_l)]
147
+ elif v == t:
148
+ return -z[(group_id_k, group_id_l)]
149
+ else:
150
+ return 0
151
+
152
+
153
+ def add_flow_constraints(model: hp.HighsModel, steiner_problem: 'SteinerProblem', z: hp.HighsVarType, y2: hp.HighsVarType) -> tuple[hp.HighsModel, dict[hp.HighsVarType]]:
154
+ """
155
+ We add the flow constraints to the HiGHS model.
156
+ :param model: HiGHS model.
157
+ :param steiner_problem: SteinerProblem-object.
158
+ :param z: decision variable z.
159
+ :param y2: decision variable y2.
160
+ :return: HiGHS model and variable(s).
161
+ """
162
+ # Decision variables (binary flow variables)
163
+ group_indices = range(len(steiner_problem.terminal_groups))
164
+ f = {(group_id, t, a): model.addVariable(0, 1, hp.HighsVarType.kInteger, name=f"f[{group_id},{a}]") for group_id in group_indices
165
+ for t in terminal_groups_without_root(steiner_problem.terminal_groups, steiner_problem.roots, group_id) for a in steiner_problem.arcs}
166
+
167
+ # Constraint 1: flow conservation
168
+ for v in steiner_problem.nodes:
169
+ for group_id in group_indices:
170
+ for t in terminal_groups_without_root(steiner_problem.terminal_groups, steiner_problem.roots, group_id):
171
+ first_term = sum(f[group_id, t, a] for a in steiner_problem.arcs if a[0] == v)
172
+ second_term = sum(f[group_id, t, a] for a in steiner_problem.arcs if a[1] == v)
173
+ left_hand_side = first_term - second_term
174
+ demand_and_supply = demand_and_supply_directed(steiner_problem, group_id, t, v, z)
175
+ model.addConstr(left_hand_side == demand_and_supply)
176
+
177
+ # Constraint 2: connection between f and y2
178
+ for group_id in group_indices:
179
+ for t in terminal_groups_without_root(steiner_problem.terminal_groups, steiner_problem.roots, group_id):
180
+ for a in steiner_problem.arcs:
181
+ left_hand_side = f[group_id, t, a]
182
+ right_hand_side = y2[group_id, a]
183
+ model.addConstr(left_hand_side <= right_hand_side)
184
+
185
+ # Constraint 3: prevent flow from leaving a terminal
186
+ for group_id in group_indices:
187
+ for t in terminal_groups_without_root(steiner_problem.terminal_groups, steiner_problem.roots, group_id):
188
+ if sum(1 for u, v in steiner_problem.arcs if u == t) > 0:
189
+ left_hand_side = sum(f[group_id, t, (u, v)] for u, v in steiner_problem.arcs if u == t)
190
+ model.addConstr(left_hand_side == 0, name="flow_3")
191
+
192
+ return model, f
193
+
194
+
195
+ def build_model(steiner_problem: 'SteinerProblem', time_limit: float = 300, logfile: str = "") -> tuple[hp.HighsModel, float]:
196
+ """
197
+ Returns the deterministic directed model.
198
+ :param steiner_problem: SteinerProblem-object.
199
+ :param time_limit: time limit in seconds for the HiGHS model. Default is 300 seconds.
200
+ :param logfile: path to logfile.
201
+ :return: HiGHS model.
202
+ """
203
+ # Create the model
204
+ logging.info("Building the model.")
205
+
206
+ model = make_model(time_limit, logfile)
207
+
208
+ # Start tracking compilation time
209
+ start_time = time.time()
210
+
211
+ # Add constraints
212
+ model, x, y1, y2, z = add_directed_constraints(model, steiner_problem)
213
+ model, f = add_flow_constraints(model, steiner_problem, z, y2)
214
+
215
+ # End tracking compilation time
216
+ end_time = time.time()
217
+ compilation_time = end_time - start_time
218
+
219
+ logging.info(f"Model built in {compilation_time:.2f} seconds.")
220
+
221
+ return model, x, y1, y2, z, f
222
+
223
+
224
+ def run_model(model: hp.HighsModel, steiner_problem: 'SteinerProblem', x: hp.HighsVarType) -> tuple[float, float, float, list[tuple]]:
225
+ """
226
+ Solves the model and returns the result.
227
+ :param model: highspy model.
228
+ :param steiner_problem: SteinerProblem-object.
229
+ :param x: highspy variable.
230
+ :return: Solution-object.
231
+ """
232
+ logging.info(f"Started with running the model...")
233
+
234
+ # Optimize model
235
+ model.minimize(sum(x[e] * steiner_problem.graph.edges[e][steiner_problem.weight] for e in steiner_problem.edges))
236
+
237
+ logging.info(f"Runtime: {model.getRunTime():.2f} seconds")
238
+
239
+ selected_edges = [e for e in steiner_problem.edges if model.variableValue(x[e]) > 0.5]
240
+ gap = model.getInfo().mip_gap
241
+ runtime = model.getRunTime()
242
+ objective = model.getObjectiveValue()
243
+
244
+ return gap, runtime, objective, selected_edges
@@ -0,0 +1,49 @@
1
+ import networkx as nx
2
+ import highspy as hp
3
+ from .mathematical_model import build_model, run_model
4
+
5
+ class SteinerProblem:
6
+ def __init__(self, graph: nx.Graph, terminal_groups: list[list], weight="weight"):
7
+ """
8
+ Initialize the SteinerProblem (can be tree or forest).
9
+ :param graph: networkx graph.
10
+ :param terminal_groups: nested list of terminals.
11
+ :param weight: edge attribute specified by this string as the edge weight.
12
+ """
13
+ self.graph = graph
14
+ self.terminal_groups = terminal_groups
15
+ self.weight = weight
16
+ self.edges = list(self.graph.edges())
17
+ self.arcs = self.edges + [(v, u) for (u, v) in self.edges]
18
+ self.nodes = list(self.graph.nodes())
19
+ self.steiner_points = set(self.nodes) - set([t for group in terminal_groups for t in group])
20
+ self.roots = [group[0] for group in self.terminal_groups]
21
+
22
+ def __repr__(self):
23
+ return f"Problem with a graph of {self.graph.number_of_nodes()} nodes and {self.graph.number_of_edges()} edges and {self.terminal_groups} as terminal groups."
24
+
25
+ def get_solution(self, time_limit: float = 300, log_file: str = "") -> 'Solution':
26
+ """
27
+ Get the solution of the Steiner Problem using HighsPy.
28
+ :param time_limit: time limit in seconds.
29
+ :param log_file: path to the log file.
30
+ :return: Solution object.
31
+ """
32
+
33
+ model, x, y1, y2, z, f = build_model(self, time_limit=time_limit, logfile=log_file)
34
+
35
+ gap, runtime, objective, selected_edges = run_model(model, self, x)
36
+
37
+ solution = Solution(gap, runtime, objective, selected_edges)
38
+
39
+ return solution
40
+
41
+ class Solution:
42
+ def __init__(self, gap: float, runtime: float, objective: float, selected_edges: list[tuple]):
43
+ self.gap = gap
44
+ self.runtime = runtime
45
+ self.objective = objective
46
+ self.selected_edges = selected_edges
47
+
48
+
49
+