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 = "*"
pyqt6 = "*"
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."""
return TestClient(app)
'''
def test_turckheim(client, request): # pylint: disable=redefined-outer-name
"""
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:
"""
start_time = time.time() # Start timer
duration_minutes = 15
duration_minutes = 20
response = client.post(
"/trip/new",
@ -45,8 +45,8 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
# Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
for elem in landmarks :
print(elem)
# checks :
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 2==3
'''
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.
@ -87,16 +89,16 @@ def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
# Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
for elem in landmarks :
print(elem)
# checks :
assert response.status_code == 200 # check for successful planning
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 2 == 3
assert 2 == 3
'''
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.

View File

@ -1,6 +1,6 @@
import yaml, logging
import numpy as np
import pulp as pl
from scipy.optimize import linprog
from collections import defaultdict, deque
@ -9,6 +9,9 @@ from .get_time_separation import get_time
from ..constants import OPTIMIZER_PARAMETERS_PATH
logging.getLogger('pulp').setLevel(level=logging.CRITICAL)
class Optimizer:
logger = logging.getLogger(__name__)
@ -62,7 +65,7 @@ class Optimizer:
b_ub[0] = round(max_time*self.overshoot)
for i, spot1 in enumerate(landmarks) :
c[i] = -spot1.attractiveness
c[i] = spot1.attractiveness
for j in range(i+1, L) :
if i !=j :
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.
"""
# 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
for i in range(1, L-1):
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
the right-hand side of the inequality constraints.
"""
# b = []
upper_ind = np.triu_indices(L,0,L)
up_ind_x = upper_ind[0]
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
incr = 0
for i in range(int((L*L+L)/2)) :
@ -144,14 +143,12 @@ class Optimizer:
b[L+incr] = 1
incr += 1
# return A[~np.all(A == 0, axis=1)], b
def init_eq_not_stay(self, landmarks: list):
"""
Generate constraints to prevent staying in the same position (e.g., removing d11, d22, d33, etc.).
-> 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:
L (int): Number of landmarks.
@ -168,7 +165,7 @@ class Optimizer:
A_eq = np.zeros((L+2+incr, L*L), dtype=np.int8)
b_eq = np.zeros(L+2+incr, dtype=np.int8)
l = np.zeros((L, L), dtype=np.int8)
# Set diagonal elements to 1 (to prevent staying in the same position)
np.fill_diagonal(l, 1)
@ -220,7 +217,6 @@ class Optimizer:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
ones = np.ones(L, dtype=np.int8)
# Fill-in rows 4 to L+2
for i in range(1, L-1) : # Prevent stacked ones
for j in range(L) :
@ -291,7 +287,7 @@ class Optimizer:
if i in vertices_visited :
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)
@ -320,7 +316,7 @@ class Optimizer:
l[0, g*L + s] = 1
l[1, s*L + g] = 1
return l, np.zeros(2, dtype=np.int8)
return l, [0, 0]
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
print(f"resx = ")
# for r in resx : print(r)
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.
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
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]
# Extract all journeys
@ -402,7 +400,7 @@ class Optimizer:
stack.append(neighbor)
return journey_nodes
def get_order(self, resx):
"""
@ -414,10 +412,8 @@ class Optimizer:
Returns:
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
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
@ -430,14 +426,14 @@ class Optimizer:
order = [0]
current = 0
used_indices = set() # Track visited index pairs
while True:
# Find index of the current node in ind_a
try:
i = ind_a.index(current)
except ValueError:
break # No more links, stop the search
if i in used_indices:
break # Prevent infinite loops
@ -454,7 +450,6 @@ class Optimizer:
return order
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.
@ -466,7 +461,6 @@ class Optimizer:
Returns:
list[Landmark]]: The updated linked list of landmarks with travel times
"""
L = []
j = 0
while j < len(order)-1 :
@ -485,11 +479,10 @@ class Optimizer:
next.location = (round(next.location[0], 5), round(next.location[1], 5))
next.must_do = True
L.append(next)
return L
# Main optimization pipeline
def solve_optimization(
self,
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.")
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)
x_bounds = [(0, 1)]*L*L
# Solve linear programming problem
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)
# Solve linear programming problem with PulP
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.")
# 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.")
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
circles = self.is_connected(res.x)
#nodes, edges = is_connected(res.x)
circles = self.is_connected(solution)
i = 0
timeout = 80
while circles is not None and i < timeout:
i += 1
# print(f"Iteration {i} of fixing circles")
A, b = self.prevent_config(res.x)
A_ub = np.vstack((A_ub, A))
b_ub = np.concatenate((b_ub, b))
print("ok1")
A, b = self.prevent_config(solution)
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 :
A, b = self.prevent_circle(circle, L)
A_eq = np.vstack((A_eq, A))
b_eq = np.concatenate((b_eq, b))
prob += (pl.lpSum([A[0][j] * x[j // L][j % L] for j in range(L*L)]) == b[0])
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.')
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 :
break
@ -594,8 +597,8 @@ class Optimizer:
raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.")
# 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]
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