diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7f00b59c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} diff --git a/docs/source/mods/index.rst b/docs/source/mods/index.rst index 473044c4..fa5e16d3 100644 --- a/docs/source/mods/index.rst +++ b/docs/source/mods/index.rst @@ -12,3 +12,4 @@ The Opti Mods mwis weighted-matching workforce + networkdesign diff --git a/docs/source/mods/networkdesign.rst b/docs/source/mods/networkdesign.rst new file mode 100644 index 00000000..8a52241e --- /dev/null +++ b/docs/source/mods/networkdesign.rst @@ -0,0 +1,182 @@ +.. This template should be copied to docs/source/mods/.rst + +Network Design +========== + +A little background on the proud history of mathprog in this field. + +Also data science. + +Problem Specification +--------------------- + +Give a brief overview of the problem being solved. + + +.. tabs:: + + .. tab:: Graph Theory + + For a given graph :math:`G` with set of vertices :math:`V` and potential edges + :math:`E` as well as a set of commodities :math:`K`. + + Each potential edge :math:`(i,j)\in E` has the following attributes: + + - flow cost: :math:`c_{ij}\in \mathbb{R}`; + - fixed cost for building the arc: :math:`f_{ij} \in \mathbb{R}`; + - and capacity: :math:`B_{ij}\in\mathbb{R}`. + + Each commodity :math:`k \in K` has the following attributes: + + - origin :math:`o_k \in V`; + - destination: :math:`d_k \in V`; + - and demand: :math:`D_k \in \mathbb{R}` + + + Also, each vertex :math:`i\in V` has a demand :math:`d_i\in\mathbb{R}`. + This value can be positive (requesting flow), negative (supplying + flow), or 0. + + The problem can be stated as finding a the flow with minimal total cost + such that: + + - the demand at each vertex is met; + - and, the flow is capacity feasible. + + .. tab:: Optimization Model + + Let us define a set of continuous variables :math:`x_{ij}` to represent + the amount of non-negative (:math:`\geq 0`) flow going through an edge + :math:`(i,j)\in E`. + + + The mathematical formulation can be stated as follows: + + .. math:: + + + \begin{alignat}{2} + \min \quad &\sum_k \sum_{(i,j)\in A} W^k c_{ij} x^k_{ij} + \sum_{(i,j)\in A} f_{ij} y_{ij}\\ + \text{s.t.} \quad &\sum_{j\in \delta^+(i)} x^k_{ij} - \sum_{j\in \delta^-(i)} x^k_{ji} =\begin{cases}1 &\text{if }i=o_k\\-1 &\text{if }i=d_k\\0&\text{otherwise}\end{cases} \quad&\forall\ i\in N,\ k\in K\\ + &\sum_{k\in K} W^k x^k_{ij} \le C_{ij} y_{ij} & \forall\ (i,j)\in A\\ + & x^k_{ij}\geq 0,\ \ y_{ij}\in\{0,1\} &\forall\ (i,j)\in A,\ k\in K + \end{alignat} + + Where :math:`\delta^+(\cdot)` (:math:`\delta^-(\cdot)`) denotes the + outgoing (incoming) neighours. + + The objective minimises the total cost over all edges. + + The first constraints ensure flow balance for all vertices. That is, for + a given node, the incoming flow (sum over all incoming edges to this + node) minus the outgoing flow (sum over all outgoing edges from this + node) is equal to the demand. Clearly, in the case when the demand is 0, + the outgoing flow must be equal to the incoming flow. When the demand is + negative, this node can supply flow to the network (outgoing term is + larger), and conversely when the demand is negative, this node can + request flow from the network (incoming term is larger). + + The last constraints ensure non-negativity of the variables and that the + capacity per edge is not exceeded. +Give examples of the various input data structures. These inputs should be fixed, +so use doctests where possible. + +.. testsetup:: mod + + # Set pandas options for displaying dataframes, if needed + import pandas as pd + pd.options.display.max_rows = 10 + +.. tabs:: + + .. tab:: ``availability`` + + Give interpretation of input data. + + .. doctest:: mod + :options: +NORMALIZE_WHITESPACE + + >>> from gurobi_optimods import datasets + >>> data = datasets.load_workforce() + >>> data.availability + Worker Shift + 0 Amy 2022-07-02 + 1 Amy 2022-07-03 + 2 Amy 2022-07-05 + 3 Amy 2022-07-07 + 4 Amy 2022-07-09 + .. ... ... + 67 Gu 2022-07-10 + 68 Gu 2022-07-11 + 69 Gu 2022-07-12 + 70 Gu 2022-07-13 + 71 Gu 2022-07-14 + + [72 rows x 2 columns] + + In the model, this corresponds to ... + + .. tab:: ``shift_requirements`` + + Another bit of input data (perhaps a secondary table) + +| + +Code +---- + +Self contained code example to run the mod from an example dataset. Example +datasets should bd included in the ``gurobi_optimods.datasets`` module for +easy access by users. + +.. testcode:: mod + + import pandas as pd + + from gurobi_optimods.datasets import load_mod_data + from gurobi_optimods.mod import solve_mod + + + data = load_mod_data() + solution = solve_mod(data.table1, data.table2) + +.. A snippet of the Gurobi log output here won't show in the rendered page, + but serves as a doctest to make sure the code example runs. The ... lines + are meaningful here, they will match anything in the output test. + +.. testoutput:: mod + :hide: + + ... + Optimize a model with 14 rows, 72 columns, and 72 nonzeros + ... + Optimal objective + ... + +The model is solved as an LP/MIP/QP by Gurobi. + +.. You can include the full Gurobi log output here for the curious reader. + It will be visible as a collapsible section. + +.. collapse:: View Gurobi Logs + + .. code-block:: text + + Gurobi Optimizer version 9.5.1 build v9.5.1rc2 (mac64[x86]) + Optimize a model with ... + Best obj ... Best bound ... + +| + +Solution +-------- + +Show the solution. One way is to use doctests to display simple shell outputs +(see the workforce example). This can be done simply by pasting outputs +directly from a python shell. Another option is to include and display figures +(see the graph matching examples). + +.. doctest:: mod + :options: +NORMALIZE_WHITESPACE + + >>> diff --git a/src/gurobi_optimods/data/commodities.csv b/src/gurobi_optimods/data/commodities.csv new file mode 100644 index 00000000..924063df --- /dev/null +++ b/src/gurobi_optimods/data/commodities.csv @@ -0,0 +1,3 @@ +Commodity, Origin, Destination, Demand +0, 0, 4, 10 +1, 2, 4, 15 diff --git a/src/gurobi_optimods/data/network_flow.gml b/src/gurobi_optimods/data/network_flow.gml new file mode 100644 index 00000000..d6b54296 --- /dev/null +++ b/src/gurobi_optimods/data/network_flow.gml @@ -0,0 +1,82 @@ +graph [ + directed 1 + node [ + id 0 + label "0" + demand 20 + ] + node [ + id 1 + label "1" + demand 0 + ] + node [ + id 2 + label "2" + demand 0 + ] + node [ + id 3 + label "3" + demand -5 + ] + node [ + id 4 + label "4" + demand -15 + ] + edge [ + source 0 + target 1 + capacity 15 + cost 4 + ] + edge [ + source 0 + target 2 + capacity 8 + cost 4 + ] + edge [ + source 1 + target 3 + capacity 4 + cost 2 + ] + edge [ + source 1 + target 2 + capacity 20 + cost 2 + ] + edge [ + source 1 + target 4 + capacity 10 + cost 6 + ] + edge [ + source 2 + target 3 + capacity 15 + cost 1 + ] + edge [ + source 2 + target 4 + capacity 5 + cost 3 + ] + edge [ + source 3 + target 4 + capacity 20 + cost 2 + ] + edge [ + source 4 + target 2 + capacity 4 + cost 3 + ] +] diff --git a/src/gurobi_optimods/datasets.py b/src/gurobi_optimods/datasets.py index a0336a18..1bdf7a82 100644 --- a/src/gurobi_optimods/datasets.py +++ b/src/gurobi_optimods/datasets.py @@ -4,10 +4,11 @@ """ import pathlib - +import matplotlib.pyplot as plt import numpy as np import pandas as pd import scipy.sparse as sp +import csv try: import networkx as nx @@ -18,6 +19,8 @@ _convert_pandas_to_digraph, _convert_pandas_to_scipy, ) +import networkx as nx + DATA_FILE_DIR = pathlib.Path(__file__).parent / "data" @@ -88,3 +91,30 @@ def load_diet(): foods=pd.read_csv(DATA_FILE_DIR / "diet-foods.csv"), nutrition_values=pd.read_csv(DATA_FILE_DIR / "diet-values.csv"), ) + + +def load_commodities(): + return pd.read_csv(DATA_FILE_DIR / "commodities.csv") + + +def load_network_design(): + G = nx.DiGraph() + G.add_edge(0, 1, capacity=15, fixed_cost=4, flow_cost=3) + G.add_edge(0, 2, capacity=8, fixed_cost=4, flow_cost=5) + G.add_edge(1, 3, capacity=4, fixed_cost=2, flow_cost=1) + G.add_edge(1, 2, capacity=20, fixed_cost=2, flow_cost=2) + G.add_edge(1, 4, capacity=10, fixed_cost=6, flow_cost=1) + G.add_edge(2, 3, capacity=15, fixed_cost=1, flow_cost=5) + G.add_edge(3, 4, capacity=20, fixed_cost=2, flow_cost=4) + G.add_edge(2, 4, capacity=5, fixed_cost=3, flow_cost=6) + G.add_edge(4, 2, capacity=4, fixed_cost=3, flow_cost=6) + # nx.draw(G, with_labels=True) + # plt.draw() # pyplot draw() + # plt.show() + # nx.write_gml(G, "network_design1.gml") + return G + + +def _create_feasible_commodities(): + + return diff --git a/src/gurobi_optimods/network_design.py b/src/gurobi_optimods/network_design.py new file mode 100644 index 00000000..86f52fae --- /dev/null +++ b/src/gurobi_optimods/network_design.py @@ -0,0 +1,116 @@ +import gurobipy as gp +from gurobipy import GRB +import networkx as nx +from typing import Dict +from gurobi_optimods.utils import optimod +import matplotlib.pyplot as plt +from gurobi_optimods.datasets import load_network_design + + +@optimod() +def solve_network_design(G: nx.DiGraph, commodities: Dict, *, create_env): + """Solve a fixed-cost network design problem on a given graph for a given set of commodities + + :param G: A graph specified as a networkx graph + :type G: :class:`nx.DiGraph` + :param commodities: A dictionary where the keys correpond to different commodity labels and the values are triples + containing the origin node, destination node, and the quantity. + :type G: :class:`Dict` + :param silent: silent=True suppresses all console output (defaults to False) + :type silent: bool + :param logfile: Write all mod output to the given file path (defaults to None: no log) + :type logfile: str + :return: A subgraph of the original graph specifying the maximum matching + :rtype: :class:`nx.Graph` + """ + + if isinstance(G, nx.Graph): + return _network_design_networkx(G, commodities, create_env) + else: + raise ValueError(f"Unknown graph type: {type(G)}") + + +def _network_design_networkx(G, commodities, create_env): + + _ensure_origins_desinations_are_nodes(G.nodes(), commodities) + with create_env() as env, gp.Model(env=env) as m: + + x = { + (i, j, k): m.addVar(vtype=GRB.CONTINUOUS, name=f"flow-{i},{j},{k}") + for (i, j) in G.edges() + for k in commodities + } + y = { + (i, j): m.addVar(vtype=GRB.BINARY, name=f"arc-{i},{j}") + for (i, j) in G.edges() + } + + m.setObjective( + gp.quicksum( + gp.quicksum( + edge["flow_cost"] * commodity["Demand"] * x[i, j, k] + for k, commodity in commodities.items() + ) + + edge["fixed_cost"] * y[i, j] + for (i, j, edge) in G.edges(data=True) + ) + ) + + m._path_constraints = { + (i, k): m.addConstr( + gp.quicksum([x[i, j, k] for j in G.successors(i)]) + - gp.quicksum([x[j, i, k] for j in G.predecessors(i)]) + == ( + 1 + if i == commodity["Origin"] + else (-1 if i == commodity["Destination"] else 0) + ), + name=f"path-{i}-{k}", + ) + for i in G.nodes() + for k, commodity in commodities.items() + } + + m._capacity_constraints = { + (i, j): m.addConstr( + gp.quicksum( + commodity["Demand"] * x[i, j, k] + for k, commodity in commodities.items() + ) + <= arc["capacity"] * y[i, j], + name=f"capacity-{i}-{j}", + ) + for (i, j, arc) in G.edges(data=True) + } + + m.optimize() + if m.Status == GRB.INFEASIBLE: + raise ValueError("Unsatisfiable flows") + + # Create a new graph with selected edges in the match + G_new = nx.DiGraph() + for (i, j) in G.edges(): + if y[(i, j)].X > 0.5: + print(y[(i, j)].X) + G_new.add_edge(i, j, flow={k: x[i, j, k].X for k in commodities}) + + return m.ObjVal, G_new + + +def _ensure_origins_desinations_are_nodes(nodes, commodities): + + for k, commodity in commodities.items(): + assert ( + commodity["Origin"] in nodes + ), f"Origin {commodity['Origin']} of commodity {k} not a node" + assert ( + commodity["Destination"] in nodes + ), f"Destination {commodity['Destination']} of commodity {k} not a node" + + +def _draw_network_design_with_solution(original_graph, solution_graph): + print("hi") + + nx.draw(original_graph, with_labels=True) + plt.draw() # pyplot draw() + plt.show() diff --git a/tests/test_network_design.py b/tests/test_network_design.py new file mode 100644 index 00000000..d8c55b77 --- /dev/null +++ b/tests/test_network_design.py @@ -0,0 +1,77 @@ +import unittest + +from gurobi_optimods.network_design import solve_network_design +from gurobi_optimods.datasets import load_network_design + +try: + import networkx as nx +except ImportError: + nx = None + + +def create_tiny_graph(): + G = nx.DiGraph() + G.add_edge(0, 1, capacity=1, fixed_cost=1, flow_cost=1) + return G + + +@unittest.skipIf(nx is None, "networkx is not installed") +class TestNetworkDesign(unittest.TestCase): + def test_empty(self): + # Not sure what should happen for an empty graph + G = nx.DiGraph() + solve_network_design(G, {}) + + def test_tiny_possible(self): + # Tiny graph where the only answer is to build the edge (0, 1) + G = create_tiny_graph() + commodities = {0: {"Origin": 0, "Destination": 1, "Demand": 1}} + sol, graph = solve_network_design(G, commodities) + self.assertEqual(sol, 2) + self.assertEqual(list(graph.edges(data=True)), [(0, 1, {"flow": {0: 1}})]) + + def test_tiny_flow_exceeds_capacity(self): + # Trivial graph but commodity quantity exceeds capacity + G = create_tiny_graph() + commodities = {0: {"Origin": 0, "Destination": 1, "Demand": 2}} + with self.assertRaisesRegex(ValueError, "Unsatisfiable flows"): + solve_network_design(G, commodities) + + def test_tiny_no_path_exists(self): + # Trivial graph but no path exists for commodity from (1, 0) + G = create_tiny_graph() + commodities = {0: {"Origin": 1, "Destination": 0, "Demand": 1}} + with self.assertRaisesRegex(ValueError, "Unsatisfiable flows"): + solve_network_design(G, commodities) + + def test_origin_does_not_exist(self): + G = create_tiny_graph() + commodities = {0: {"Origin": 2, "Destination": 1, "Demand": 1}} + with self.assertRaisesRegex(AssertionError, "Origin*"): + solve_network_design(G, commodities) + + def test_destination_does_not_exist(self): + G = create_tiny_graph() + commodities = {0: {"Origin": 0, "Destination": 2, "Demand": 1}} + with self.assertRaisesRegex(AssertionError, "Destination*"): + solve_network_design(G, commodities) + + def test_network_flow(self): + G = load_network_design() + commodities = { + 0: {"Origin": 0, "Destination": 4, "Demand": 10}, + 1: {"Origin": 2, "Destination": 4, "Demand": 15}, + } + sol, graph = solve_network_design(G, commodities) + self.assertEqual(sol, 176) + print(list(graph.edges(data=True))) + # self.assertEqual( + # list(graph.edges(data=True)), + # [ + # (0, 1, {"flow": {0: 1.0, 1: 0.0}}), + # (1, 4, {"flow": {0: 1.0, 1: 0.0}}), + # (2, 3, {"flow": {0: 0.0, 1: 2.0 / 3}}), + # (2, 4, {"flow": {0: 0.0, 1: 1.0 / 3}}), + # (3, 4, {"flow": {0: 0.0, 1: 2.0 / 3}}), + # ], + # )