From f86cafa5b131a45f69e2200fe50f7cc30ff4b588 Mon Sep 17 00:00:00 2001
From: Bruno Rosendo <brunomrosendo@hotmail.com>
Date: Tue, 14 May 2024 19:40:41 +0100
Subject: [PATCH] Refactored step-based models into base class

---
 src/model/qubo/StepQuboVRP.py     | 144 ++++++++++++++++++++++++++++++
 src/model/qubo/cvrp/MultiCVRP.py  |  97 ++++----------------
 src/model/qubo/rpp/InfiniteRPP.py | 112 +++++------------------
 3 files changed, 185 insertions(+), 168 deletions(-)
 create mode 100644 src/model/qubo/StepQuboVRP.py

diff --git a/src/model/qubo/StepQuboVRP.py b/src/model/qubo/StepQuboVRP.py
new file mode 100644
index 0000000..a2791b1
--- /dev/null
+++ b/src/model/qubo/StepQuboVRP.py
@@ -0,0 +1,144 @@
+from abc import ABC, abstractmethod
+
+import numpy as np
+from docplex.mp.dvar import Var
+
+from src.model.qubo.QuboVRP import QuboVRP
+
+
+class StepQuboVRP(QuboVRP, ABC):
+    """
+    A class to represent a QUBO math formulation of the step-based CVRP model.
+    This model should always be simplified, since some constraints assume the simplification.
+
+    Attributes:
+        num_steps (int): Number of max steps for each vehicle.
+        num_used_locations (int): Number of locations used in the problem, including the auxiliary depot if used.
+        epsilon (int): Small value to avoid division by zero.
+        normalization_factor (int): Value to normalize the objective function.
+    """
+
+    def __init__(
+        self,
+        num_vehicles: int,
+        trips: list[tuple[int, int, int]],
+        distance_matrix: list[list[int]],
+        locations: list[tuple[int, int]],
+        use_deliveries: bool,
+        depot: int | None = 0,
+    ):
+        self.distance_matrix = distance_matrix
+        self.num_steps = self.get_num_steps()
+        self.num_used_locations = self.get_num_used_locations()
+        self.epsilon = 0.0001
+        self.normalization_factor = np.max(self.distance_matrix) + self.epsilon
+
+        super().__init__(
+            num_vehicles, trips, distance_matrix, locations, use_deliveries, True, depot
+        )
+
+    def create_vars(self):
+        """
+        Create the variables for the VRP model.
+        """
+
+        self.x.extend(
+            self.model.binary_var(self.get_var_name(k, i, s))
+            for k in range(self.num_vehicles)
+            for i in range(self.num_used_locations)
+            for s in range(self.num_steps)
+        )
+
+    def create_constraints(self):
+        """
+        Create the constraints for the VRP model.
+        """
+
+        self.create_location_constraints()
+        self.create_vehicle_constraints()
+
+    def create_location_constraints(self):
+        """
+        Create the constraints that ensure each location is visited exactly once.
+        """
+
+        self.constraints.extend(
+            self.model.sum(
+                self.x_var(k, i, s)
+                for k in range(self.num_vehicles)
+                for s in range(self.num_steps)
+            )
+            == 1
+            for i in range(1, self.num_used_locations)
+        )
+
+    @abstractmethod
+    def create_vehicle_constraints(self):
+        """
+        Create the vehicle constraints, depending on the model.
+        """
+        pass
+
+    @abstractmethod
+    def get_num_steps(self):
+        """
+        Get the number of steps for the model.
+        """
+        pass
+
+    @abstractmethod
+    def get_num_used_locations(self):
+        """
+        Get the number of used locations for the model.
+        """
+        pass
+
+    def get_result_route_starts(self, var_dict: dict[str, float]) -> list[int]:
+        """
+        Get the starting location for each route from the variable dictionary.
+        """
+
+        route_starts = []
+
+        for k in range(self.num_vehicles):
+            for s in range(self.num_steps):
+                if self.get_var(var_dict, k, 0, s) == 0.0:
+                    start = self.get_result_location(var_dict, k, s)
+                    route_starts.append(start)
+                    break
+
+        return route_starts
+
+    def get_result_next_location(
+        self, var_dict: dict[str, float], cur_location: int
+    ) -> int | None:
+        """
+        Get the next location for a route from the variable dictionary.
+        """
+        for k in range(self.num_vehicles):
+            for s in range(self.num_steps - 1):
+                if self.get_var(var_dict, k, cur_location, s) == 1.0:
+                    return self.get_result_location(var_dict, k, s + 1)
+
+        return None
+
+    @abstractmethod
+    def get_result_location(
+        self, var_dict: dict[str, float], k: int, s: int
+    ) -> int | None:
+        """
+        Get the location for a vehicle at a given step.
+        """
+        pass
+
+    def get_var_name(self, k: int, i: int, s: int | None = None) -> str:
+        """
+        Get the name of a variable.
+        """
+
+        return f"x_{k}_{i}_{s}"
+
+    def x_var(self, k: int, i: int, s: int) -> Var:
+        return self.x[
+            k * self.num_used_locations * self.num_steps + i * self.num_steps + s
+        ]
diff --git a/src/model/qubo/cvrp/MultiCVRP.py b/src/model/qubo/cvrp/MultiCVRP.py
index 42e94d6..0f376e8 100644
--- a/src/model/qubo/cvrp/MultiCVRP.py
+++ b/src/model/qubo/cvrp/MultiCVRP.py
@@ -1,11 +1,9 @@
-import numpy as np
-from docplex.mp.dvar import Var
 from docplex.mp.linear import LinearExpr
 
-from src.model.qubo.QuboVRP import QuboVRP
+from src.model.qubo.StepQuboVRP import StepQuboVRP
 
 
-class MultiCVRP(QuboVRP):
+class MultiCVRP(StepQuboVRP):
     """
     A class to represent a QUBO math formulation of the CVRP model with all vehicles having different capacities.
 
@@ -23,26 +21,8 @@ def __init__(
         locations: list[tuple[int, int]],
     ):
         self.capacities = capacities
-        self.num_steps = len(distance_matrix) + 1
-
-        self.epsilon = 0
-        self.normalization_factor = np.max(distance_matrix) + self.epsilon
-
-        self.copy_vars = False
-
-        super().__init__(num_vehicles, [], distance_matrix, locations, False, True)
-
-    def create_vars(self):
-        """
-        Create the variables for the CVRP model.
-        """
-
-        self.x.extend(
-            self.model.binary_var(self.get_var_name(k, i, s))
-            for k in range(self.num_vehicles)
-            for i in range(self.num_locations)
-            for s in range(self.num_steps)
-        )
+        self.num_locations = len(locations)
+        super().__init__(num_vehicles, [], distance_matrix, locations, False)
 
     def create_objective(self) -> LinearExpr:
         """
@@ -55,42 +35,27 @@ def create_objective(self) -> LinearExpr:
             * self.x_var(k, i, s)
             * self.x_var(k, j, s + 1)
             for k in range(self.num_vehicles)
-            for i in range(self.num_locations)
-            for j in range(self.num_locations)
+            for i in range(self.num_used_locations)
+            for j in range(self.num_used_locations)
             for s in range(self.num_steps - 1)
         )
 
     def create_constraints(self):
         """
-        Create the constraints for the CPLEX model.
+        Create the constraints for the CVRP model.
         """
 
-        self.create_location_constraints()
-        self.create_vehicle_constraints()
+        super().create_constraints()
         self.create_capacity_constraints()
 
-    def create_location_constraints(self):
-        """
-        Create the constraints that ensure each location is visited exactly once.
-        """
-
-        self.constraints.extend(
-            self.model.sum(
-                self.x_var(k, i, s)
-                for k in range(self.num_vehicles)
-                for s in range(self.num_steps)
-            )
-            == 1
-            for i in range(1, self.num_locations)
-        )
-
     def create_vehicle_constraints(self):
         """
         Create the constraints that ensure each vehicle starts and ends at the depot.
         """
 
         self.constraints.extend(
-            self.model.sum(self.x_var(k, i, s) for i in range(self.num_locations)) == 1
+            self.model.sum(self.x_var(k, i, s) for i in range(self.num_used_locations))
+            == 1
             for k in range(self.num_vehicles)
             for s in range(self.num_steps)
         )
@@ -103,7 +68,7 @@ def create_capacity_constraints(self):
         self.constraints.extend(
             self.model.sum(
                 self.get_location_demand(i) * self.x_var(k, i, s)
-                for i in range(1, self.num_locations)
+                for i in range(1, self.num_used_locations)
                 for s in range(cur_step + 1)
             )
             <= self.capacities[k]
@@ -123,39 +88,25 @@ def get_simplified_variables(self) -> dict[str, int]:
             variables[self.get_var_name(k, 0, 0)] = 1
             variables[self.get_var_name(k, 0, self.num_steps - 1)] = 1
 
-            for i in range(1, self.num_locations):
+            for i in range(1, self.num_used_locations):
                 variables[self.get_var_name(k, i, 0)] = 0
                 variables[self.get_var_name(k, i, self.num_steps - 1)] = 0
 
         return variables
 
-    def get_result_route_starts(self, var_dict: dict[str, float]) -> list[int]:
+    def get_num_steps(self):
         """
-        Get the starting location for each route from the variable dictionary.
+        Get the number of steps for the model.
         """
-        route_starts = []
-
-        for k in range(self.num_vehicles):
-            for s in range(self.num_steps):
-                if self.get_var(var_dict, k, 0, s) == 0.0:
-                    start = self.get_result_location(var_dict, k, s)
-                    route_starts.append(start)
-                    break
 
-        return route_starts
+        return len(self.distance_matrix) + 1
 
-    def get_result_next_location(
-        self, var_dict: dict[str, float], cur_location: int
-    ) -> int | None:
+    def get_num_used_locations(self):
         """
-        Get the next location for a route from the variable dictionary.
+        Get the number of used locations for the model.
         """
-        for k in range(self.num_vehicles):
-            for s in range(self.num_steps - 1):
-                if self.get_var(var_dict, k, cur_location, s) == 1.0:
-                    return self.get_result_location(var_dict, k, s + 1)
 
-        return None
+        return self.num_locations
 
     def get_result_location(
         self, var_dict: dict[str, float], k: int, s: int
@@ -164,22 +115,12 @@ def get_result_location(
         Get the location for a vehicle at a given step.
         """
 
-        for i in range(self.num_locations):
+        for i in range(self.num_used_locations):
             if self.get_var(var_dict, k, i, s) == 1.0:
                 return i
 
         return None
 
-    def get_var_name(self, k: int, i: int, s: int | None = None) -> str:
-        """
-        Get the name of a variable.
-        """
-
-        return f"x_{k}_{i}_{s}"
-
-    def x_var(self, k: int, i: int, s: int) -> Var:
-        return self.x[k * self.num_locations * self.num_steps + i * self.num_steps + s]
-
     def get_capacity(self) -> int | list[int] | None:
         """
         Get the capacity of the vehicles.
diff --git a/src/model/qubo/rpp/InfiniteRPP.py b/src/model/qubo/rpp/InfiniteRPP.py
index 35b009e..ae9b3ea 100644
--- a/src/model/qubo/rpp/InfiniteRPP.py
+++ b/src/model/qubo/rpp/InfiniteRPP.py
@@ -1,9 +1,7 @@
-from docplex.mp.dvar import Var
+from src.model.qubo.StepQuboVRP import StepQuboVRP
 
-from src.model.qubo.QuboVRP import QuboVRP
 
-
-class InfiniteRPP(QuboVRP):
+class InfiniteRPP(StepQuboVRP):
     """
     A class to represent a QUBO math formulation of the RPP model with an infinite capacity.
     Note that this model assumes a starting point with no cost to the first node for each vehicle,
@@ -13,9 +11,7 @@ class InfiniteRPP(QuboVRP):
 
     Attributes:
         num_trips (int): Number of trips to be made.
-        num_steps (int): Number of max steps for each vehicle.
         used_locations_indices (list): Indices of the locations used in the problem, based on trip requests.
-        num_used_locations (int): Number of locations used in the problem.
     """
 
     def __init__(
@@ -27,28 +23,10 @@ def __init__(
     ):
         self.trips = trips
         self.num_trips = len(trips)
-        self.num_steps = 2 * self.num_trips + 1
-
         self.used_locations_indices = self.get_used_locations()
-        self.num_used_locations = len(self.used_locations_indices)
-
-        self.epsilon = 0
-        self.normalization_factor = max(max(distance_matrix)) + self.epsilon
 
         super().__init__(
-            num_vehicles, self.trips, distance_matrix, locations, True, True, None
-        )
-
-    def create_vars(self):
-        """
-        Create the variables for the RPP model.
-        """
-
-        self.x.extend(
-            self.model.binary_var(self.get_var_name(k, i, s))
-            for k in range(self.num_vehicles)
-            for i in range(self.num_used_locations + 1)
-            for s in range(self.num_steps)
+            num_vehicles, self.trips, distance_matrix, locations, True, None
         )
 
     def create_objective(self):
@@ -65,8 +43,8 @@ def create_objective(self):
             * self.x_var(k, i, s)
             * self.x_var(k, j, s + 1)
             for k in range(self.num_vehicles)
-            for i in range(1, self.num_used_locations + 1)
-            for j in range(1, self.num_used_locations + 1)
+            for i in range(1, self.num_used_locations)
+            for j in range(1, self.num_used_locations)
             for s in range(self.num_steps - 1)
         )
 
@@ -74,7 +52,7 @@ def create_objective(self):
         return_to_start_penalty = self.model.sum(
             self.x_var(k, i, s) * self.x_var(k, 0, s + 1)
             for k in range(self.num_vehicles)
-            for i in range(1, self.num_used_locations + 1)
+            for i in range(1, self.num_used_locations)
             for s in range(self.num_steps - 1)
         )
 
@@ -98,29 +76,6 @@ def create_trip_incentive(self):
             for s2 in range(s1 + 1, self.num_steps)
         )
 
-    def create_constraints(self):
-        """
-        Create the constraints for the RPP model.
-        """
-
-        self.create_location_constraints()
-        self.create_vehicle_constraints()
-
-    def create_location_constraints(self):
-        """
-        Create the constraints that ensure each location is visited exactly once.
-        """
-
-        self.constraints.extend(
-            self.model.sum(
-                self.x_var(k, i, s)
-                for k in range(self.num_vehicles)
-                for s in range(self.num_steps)
-            )
-            == 1
-            for i in range(1, self.num_used_locations + 1)
-        )
-
     def create_vehicle_constraints(self):
         """
         Create the constraints that ensure each vehicle can only be at one location at a time.
@@ -129,9 +84,7 @@ def create_vehicle_constraints(self):
         final_step = self.num_steps - 1
 
         self.constraints.extend(
-            self.model.sum(
-                self.x_var(k, i, s) for i in range(self.num_used_locations + 1)
-            )
+            self.model.sum(self.x_var(k, i, s) for i in range(self.num_used_locations))
             == 1
             for k in range(self.num_vehicles)
             for s in range(final_step)
@@ -140,8 +93,7 @@ def create_vehicle_constraints(self):
         # Half-hot constraint meant to reduce variables in the last step
         self.constraints.extend(
             self.model.sum(
-                self.x_var(k, i, final_step)
-                for i in range(1, self.num_used_locations + 1)
+                self.x_var(k, i, final_step) for i in range(1, self.num_used_locations)
             )
             <= 1
             for k in range(self.num_vehicles)
@@ -157,7 +109,7 @@ def get_simplified_variables(self) -> dict[str, int]:
         for k in range(self.num_vehicles):
             # Vehicles start at their respective locations and not anywhere else
             variables[self.get_var_name(k, 0, 0)] = 1
-            for i in range(1, self.num_used_locations + 1):
+            for i in range(1, self.num_used_locations):
                 variables[self.get_var_name(k, i, 0)] = 0
 
             # Vehicles can't be at the starting point at the last step (see paper)
@@ -176,28 +128,26 @@ def get_simplified_variables(self) -> dict[str, int]:
 
         return variables
 
-    def get_used_locations(self) -> list[int]:
+    def get_num_steps(self):
         """
-        Get the indices of the locations used in the problem. This helps reduce the number of variables.
+        Get the number of steps for the model.
         """
 
-        return list({location for trip in self.trips for location in trip[:2]})
+        return 2 * self.num_trips + 1
 
-    def get_result_route_starts(self, var_dict: dict[str, float]) -> list[int]:
+    def get_num_used_locations(self):
         """
-        Get the starting location for each route from the variable dictionary.
+        Get the number of used locations for the model.
         """
 
-        route_starts = []
+        return len(self.used_locations_indices) + 1
 
-        for k in range(self.num_vehicles):
-            for s in range(self.num_steps):
-                if self.get_var(var_dict, k, 0, s) == 0.0:
-                    start = self.get_result_location(var_dict, k, s)
-                    route_starts.append(start)
-                    break
+    def get_used_locations(self) -> list[int]:
+        """
+        Get the indices of the locations used in the problem. This helps reduce the number of variables.
+        """
 
-        return route_starts
+        return list({location for trip in self.trips for location in trip[:2]})
 
     def get_result_next_location(
         self, var_dict: dict[str, float], cur_location: int
@@ -206,14 +156,8 @@ def get_result_next_location(
         Get the next location for a route from the variable dictionary.
         """
 
-        cplex_location = self.used_locations_indices.index(cur_location) + 1
-
-        for k in range(self.num_vehicles):
-            for s in range(self.num_steps - 1):
-                if self.get_var(var_dict, k, cplex_location, s) == 1.0:
-                    return self.get_result_location(var_dict, k, s + 1)
-
-        return None
+        original_location = self.used_locations_indices.index(cur_location) + 1
+        return super().get_result_next_location(var_dict, original_location)
 
     def get_result_location(
         self, var_dict: dict[str, float], k: int, s: int
@@ -251,15 +195,3 @@ def is_result_feasible(self, var_dict: dict[str, float]) -> bool:
                 return False
 
         return True
-
-    def get_var_name(self, k: int, i: int, s: int | None = None) -> str:
-        """
-        Get the name of a variable.
-        """
-
-        return f"x_{k}_{i}_{s}"
-
-    def x_var(self, k: int, i: int, s: int) -> Var:
-        return self.x[
-            k * (self.num_used_locations + 1) * self.num_steps + i * self.num_steps + s
-        ]