first steps toward pulp

This commit is contained in:
Helldragon67 2025-01-15 16:48:32 +01:00
parent d62dddd424
commit 3fe6056f3c
4 changed files with 843 additions and 862 deletions

View File

@ -26,3 +26,4 @@ fastapi-cli = "*"
scikit-learn = "*" scikit-learn = "*"
pyqt6 = "*" pyqt6 = "*"
loki-logger-handler = "*" loki-logger-handler = "*"
pulp = "*"

1555
backend/Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ def client():
"""Client used to call the app.""" """Client used to call the app."""
return TestClient(app) return TestClient(app)
'''
def test_turckheim(client, request): # pylint: disable=redefined-outer-name def test_turckheim(client, request): # pylint: disable=redefined-outer-name
""" """
Test n°1 : Custom test in Turckheim to ensure small villages are also supported. Test n°1 : Custom test in Turckheim to ensure small villages are also supported.
@ -21,7 +21,7 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
request: request:
""" """
start_time = time.time() # Start timer start_time = time.time() # Start timer
duration_minutes = 15 duration_minutes = 20
response = client.post( response = client.post(
"/trip/new", "/trip/new",
@ -45,8 +45,8 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
# Add details to report # Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes) log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks : for elem in landmarks :
# print(elem) print(elem)
# checks : # checks :
assert response.status_code == 200 # check for successful planning assert response.status_code == 200 # check for successful planning
@ -56,6 +56,8 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds" assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
assert 2==3 assert 2==3
''' '''
def test_bellecour(client, request) : # pylint: disable=redefined-outer-name def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
""" """
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area. Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
@ -87,16 +89,16 @@ def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
# Add details to report # Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes) log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks : for elem in landmarks :
# print(elem) print(elem)
# checks : # checks :
assert response.status_code == 200 # check for successful planning assert response.status_code == 200 # check for successful planning
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds" assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2 assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2
# assert 2 == 3 assert 2 == 3
'''
def test_Paris(client, request) : # pylint: disable=redefined-outer-name def test_Paris(client, request) : # pylint: disable=redefined-outer-name
""" """
Test n°2 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area. Test n°2 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.

View File

@ -1,6 +1,6 @@
import yaml, logging import yaml, logging
import numpy as np import numpy as np
import pulp as pl
from scipy.optimize import linprog from scipy.optimize import linprog
from collections import defaultdict, deque from collections import defaultdict, deque
@ -9,6 +9,9 @@ from .get_time_separation import get_time
from ..constants import OPTIMIZER_PARAMETERS_PATH from ..constants import OPTIMIZER_PARAMETERS_PATH
logging.getLogger('pulp').setLevel(level=logging.CRITICAL)
class Optimizer: class Optimizer:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -62,7 +65,7 @@ class Optimizer:
b_ub[0] = round(max_time*self.overshoot) b_ub[0] = round(max_time*self.overshoot)
for i, spot1 in enumerate(landmarks) : for i, spot1 in enumerate(landmarks) :
c[i] = -spot1.attractiveness c[i] = spot1.attractiveness
for j in range(i+1, L) : for j in range(i+1, L) :
if i !=j : if i !=j :
t = get_time(spot1.location, landmarks[j].location) t = get_time(spot1.location, landmarks[j].location)
@ -102,8 +105,6 @@ class Optimizer:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
""" """
# First constraint: each landmark is visited exactly once # First constraint: each landmark is visited exactly once
# A = np.zeros((L-1, L*L), dtype=np.int8)
# b = []
# Fill-in row 2 until row L-2 # Fill-in row 2 until row L-2
for i in range(1, L-1): for i in range(1, L-1):
A[i, L*i:L*(i+1)] = np.ones(L, dtype=np.int16) A[i, L*i:L*(i+1)] = np.ones(L, dtype=np.int16)
@ -129,12 +130,10 @@ class Optimizer:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and tuple[np.ndarray, list[int]]: Inequality constraint coefficients and
the right-hand side of the inequality constraints. the right-hand side of the inequality constraints.
""" """
# b = []
upper_ind = np.triu_indices(L,0,L) upper_ind = np.triu_indices(L,0,L)
up_ind_x = upper_ind[0] up_ind_x = upper_ind[0]
up_ind_y = upper_ind[1] up_ind_y = upper_ind[1]
# A = np.zeros((len(up_ind_x[1:]),L*L), dtype=np.int8)
# Fill-in rows L to 2*L-1 # Fill-in rows L to 2*L-1
incr = 0 incr = 0
for i in range(int((L*L+L)/2)) : for i in range(int((L*L+L)/2)) :
@ -144,14 +143,12 @@ class Optimizer:
b[L+incr] = 1 b[L+incr] = 1
incr += 1 incr += 1
# return A[~np.all(A == 0, axis=1)], b
def init_eq_not_stay(self, landmarks: list): def init_eq_not_stay(self, landmarks: list):
""" """
Generate constraints to prevent staying in the same position (e.g., removing d11, d22, d33, etc.). Generate constraints to prevent staying in the same position (e.g., removing d11, d22, d33, etc.).
-> Adds 1 row of constraints -> Adds 1 row of constraints
-> Pre-allocates A_eq for the rest of the computations with (L+2 + dynamic incr) rows -> Pre-allocates A_eq for the rest of the computations with (L+ 2 + dynamic incr) rows
Args: Args:
L (int): Number of landmarks. L (int): Number of landmarks.
@ -220,7 +217,6 @@ class Optimizer:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints. tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
""" """
ones = np.ones(L, dtype=np.int8) ones = np.ones(L, dtype=np.int8)
# Fill-in rows 4 to L+2 # Fill-in rows 4 to L+2
for i in range(1, L-1) : # Prevent stacked ones for i in range(1, L-1) : # Prevent stacked ones
for j in range(L) : for j in range(L) :
@ -291,7 +287,7 @@ class Optimizer:
if i in vertices_visited : if i in vertices_visited :
h[i*L:i*L+L] = ones h[i*L:i*L+L] = ones
return h, np.array([len(vertices_visited)-1]) return h, len(vertices_visited)-1
# Prevents the creation of the same circle (both directions) # Prevents the creation of the same circle (both directions)
@ -320,7 +316,7 @@ class Optimizer:
l[0, g*L + s] = 1 l[0, g*L + s] = 1
l[1, s*L + g] = 1 l[1, s*L + g] = 1
return l, np.zeros(2, dtype=np.int8) return l, [0, 0]
def is_connected(self, resx) : def is_connected(self, resx) :
@ -335,13 +331,15 @@ class Optimizer:
""" """
resx = np.round(resx).astype(np.int8) # round all elements and cast them to int resx = np.round(resx).astype(np.int8) # round all elements and cast them to int
print(f"resx = ")
# for r in resx : print(r)
N = len(resx) # length of res N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def. L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0] nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L)) nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0] # removed .tolist() ind_a = nonzero_tup[0]
ind_b = nonzero_tup[1] ind_b = nonzero_tup[1]
# Extract all journeys # Extract all journeys
@ -414,9 +412,7 @@ class Optimizer:
Returns: Returns:
list[int]: A list containing the visit order. list[int]: A list containing the visit order.
""" """
resx = np.round(resx).astype(np.uint8) # must contain only 0 and 1
# first round the results to have only 0-1 values
resx = np.round(resx).astype(np.uint8) # round all elements and cast them to int
N = len(resx) # length of res N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def. L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
@ -454,7 +450,6 @@ class Optimizer:
return order return order
def link_list(self, order: list[int], landmarks: list[Landmark])->list[Landmark] : def link_list(self, order: list[int], landmarks: list[Landmark])->list[Landmark] :
""" """
Compute the time to reach from each landmark to the next and create a list of landmarks with updated travel times. Compute the time to reach from each landmark to the next and create a list of landmarks with updated travel times.
@ -466,7 +461,6 @@ class Optimizer:
Returns: Returns:
list[Landmark]]: The updated linked list of landmarks with travel times list[Landmark]]: The updated linked list of landmarks with travel times
""" """
L = [] L = []
j = 0 j = 0
while j < len(order)-1 : while j < len(order)-1 :
@ -489,7 +483,6 @@ class Optimizer:
return L return L
# Main optimization pipeline
def solve_optimization( def solve_optimization(
self, self,
max_time: int, max_time: int,
@ -527,65 +520,75 @@ class Optimizer:
self.logger.debug(f"Optimizing with {A_ub.shape[0]} + {A_eq.shape[0]} = {A_ub.shape[0] + A_eq.shape[0]} constraints.") self.logger.debug(f"Optimizing with {A_ub.shape[0]} + {A_eq.shape[0]} = {A_ub.shape[0] + A_eq.shape[0]} constraints.")
print(A_eq)
print('\n\n')
print(b_eq)
print('\n\n')
# A, b = self.respect_user_must_do(landmarks) # Check if there are user_defined must_see. Also takes care of start/goal
# if len(b) > 0 :
# A_eq = np.vstack((A_eq, A), dtype=np.int8)
# b_eq += b
# A, b = self.respect_user_must_avoid(landmarks) # Check if there are user_defined must_see. Also takes care of start/goal
# if len(b) > 0 :
# A_eq = np.vstack((A_eq, A), dtype=np.int8)
# b_eq += b
# A, b = self.respect_start_finish(L) # Force start and finish positions
# A_eq = np.vstack((A_eq, A), dtype=np.int8)
# b_eq += b
# A, b = self.respect_order(L) # Respect order of visit (only works when max_time is limiting factor)
# A_eq = np.vstack((A_eq, A), dtype=np.int8)
# b_eq += b
# until here opti
# SET BOUNDS FOR DECISION VARIABLE (x can only be 0 or 1) # SET BOUNDS FOR DECISION VARIABLE (x can only be 0 or 1)
x_bounds = [(0, 1)]*L*L x_bounds = [(0, 1)]*L*L
# Solve linear programming problem # Solve linear programming problem with PulP
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3) prob = pl.LpProblem("OptimizationProblem", pl.LpMaximize)
x = [pl.LpVariable(f"x_{i}", lowBound=x_bounds[i][0], upBound=x_bounds[i][1], cat='Binary') for i in range(L*L)]
# Add the objective function
prob += pl.lpSum([c[i] * x[i] for i in range(L*L)])
# Add Inequality Constraints (A_ub @ x <= b_ub)
for i in range(len(b_ub)): # Iterate over rows of A_ub
prob += (pl.lpSum([A_ub[i][j] * x[j] for j in range(L*L)]) <= b_ub[i])
# 5. Add Equality Constraints (A_eq @ x == b_eq)
for i in range(len(b_eq)): # Iterate over rows of A_eq
prob += (pl.lpSum([A_eq[i][j] * x[j] for j in range(L*L)]) == b_eq[i])
# 6. Solve the problem
prob.solve(pl.PULP_CBC_CMD(msg=True))
# 7. Extract Results
status = pl.LpStatus[prob.status]
solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1)
print(status)
self.logger.debug("First results are out. Looking out for circles and correcting.") self.logger.debug("First results are out. Looking out for circles and correcting.")
# Raise error if no solution is found. FIXME: for now this throws the internal server error # Raise error if no solution is found. FIXME: for now this throws the internal server error
if not res.success : if status != 'Optimal' :
self.logger.error("The problem is overconstrained, no solution on first try.") self.logger.error("The problem is overconstrained, no solution on first try.")
raise ArithmeticError("No solution could be found, the problem is overconstrained. Try with a longer trip (>30 minutes).") raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
# If there is a solution, we're good to go, just check for connectiveness # If there is a solution, we're good to go, just check for connectiveness
circles = self.is_connected(res.x) circles = self.is_connected(solution)
#nodes, edges = is_connected(res.x)
i = 0 i = 0
timeout = 80 timeout = 80
while circles is not None and i < timeout: while circles is not None and i < timeout:
i += 1 i += 1
# print(f"Iteration {i} of fixing circles") # print(f"Iteration {i} of fixing circles")
A, b = self.prevent_config(res.x) print("ok1")
A_ub = np.vstack((A_ub, A)) A, b = self.prevent_config(solution)
b_ub = np.concatenate((b_ub, b)) print("ok2")
print(f"A: {A}")
print(f"b: {b}")
try :
prob += pl.lpSum([A[j] * x[j // L][j % L] for j in range(L * L)]) == b
except Exception as exc :
self.logger.error(f'Unexpected error occured', details=exc)
raise Exception from exc
print("ok3")
for circle in circles : for circle in circles :
A, b = self.prevent_circle(circle, L) A, b = self.prevent_circle(circle, L)
A_eq = np.vstack((A_eq, A)) prob += (pl.lpSum([A[0][j] * x[j // L][j % L] for j in range(L*L)]) == b[0])
b_eq = np.concatenate((b_eq, b)) prob += (pl.lpSum([A[1][j] * x[j // L][j % L] for j in range(L*L)]) == b[1])
print("ok4")
prob.solve(pl.PULP_CBC_CMD(msg=True))
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3) status = pl.LpStatus[prob.status]
solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1)
if not res.success : if status != 'Optimal' :
self.logger.error(f'Unexpected error after {timeout} iterations of fixing circles.') self.logger.error(f'Unexpected error after {timeout} iterations of fixing circles.')
raise ArithmeticError("Solving failed because of overconstrained problem") raise ArithmeticError("Solving failed because of overconstrained problem")
circles = self.is_connected(res.x)
#nodes, edges = is_connected(res.x) circles = self.is_connected(solution)
if circles is None : if circles is None :
break break
@ -594,8 +597,8 @@ class Optimizer:
raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.") raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.")
# Sort the landmarks in the order of the solution # Sort the landmarks in the order of the solution
order = self.get_order(res.x) order = self.get_order(solution)
tour = [landmarks[i] for i in order] tour = [landmarks[i] for i in order]
self.logger.debug(f"Re-optimized {i} times, score: {int(-res.fun)}") self.logger.debug(f"Re-optimized {i} times, score: {int(pl.value(prob.objective))}")
return tour return tour