Skip to content
This repository was archived by the owner on Jan 28, 2026. It is now read-only.

Commit 1d56685

Browse files
author
ge85riz
committed
✨ feat(backend): improve seat allocation algorithm and get configurations from environment variables.
1 parent 51c0acc commit 1d56685

9 files changed

Lines changed: 167 additions & 40 deletions

File tree

backend/algo(table).py

Lines changed: 134 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import math
55
import sys
66

7-
def assign_tables(employees_data, tables_data, constraints_config, time_limit_sec=30.0):
7+
def assign_tables(search_workers_count, employees_data, tables_data, constraints_config, time_limit_sec=30.0):
88
"""
99
Assigns employee groups to tables to maximize diversity using Google OR-Tools.
1010
Handles groups of people represented by a single employee record with an "Anzahl" > 1.
@@ -39,7 +39,14 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
3939

4040
if total_seats_required > total_capacity:
4141
print(f"Error: Not enough capacity ({total_capacity}) for all people ({total_seats_required}).")
42-
return employees_data
42+
sys.exit(2)
43+
44+
# Check for feasibility issues
45+
max_group_size = df_employees['Anzahl'].max()
46+
max_table_capacity = df_tables['Capacity'].max()
47+
if max_group_size > max_table_capacity:
48+
print(f"Error: Largest group ({max_group_size}) exceeds largest table capacity ({max_table_capacity}).")
49+
sys.exit(3)
4350

4451
# --- 2. Create the CP-SAT Model ---
4552
model = cp_model.CpModel()
@@ -50,9 +57,11 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
5057
assignment[(g_idx, t_idx)] = model.NewBoolVar(f'assign_g{g_idx}_t{t_idx}')
5158

5259
# --- 3. Add Core Constraints ---
60+
# Each group assigned to exactly one table
5361
for g_idx in range(num_groups):
5462
model.AddExactlyOne([assignment[(g_idx, t_idx)] for t_idx in range(num_tables)])
5563

64+
# Table capacity constraints
5665
for t_idx in range(num_tables):
5766
table_capacity = df_tables.loc[t_idx, 'Capacity']
5867
groups_at_table = [df_employees.loc[g_idx, 'Anzahl'] * assignment[(g_idx, t_idx)] for g_idx in range(num_groups)]
@@ -63,10 +72,16 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
6372

6473
# Add a penalty for each table used to encourage filling tables
6574
for t_idx in range(num_tables):
75+
table_capacity = df_tables.loc[t_idx, 'Capacity']
76+
77+
# Calculate actual occupancy
78+
groups_at_table = [df_employees.loc[g_idx, 'Anzahl'] * assignment[(g_idx, t_idx)] for g_idx in range(num_groups)]
79+
total_seated_at_table = cp_model.LinearExpr.Sum(groups_at_table)
80+
81+
# Encourage table usage with diminishing returns
6682
is_table_used = model.NewBoolVar(f'table_used_{t_idx}')
6783
assignments_to_table = [assignment[(g_idx, t_idx)] for g_idx in range(num_groups)]
6884
model.AddMaxEquality(is_table_used, assignments_to_table)
69-
objective_terms.append(-1 * is_table_used)
7085

7186
# Add a penalty for each empty seat to encourage fuller tables
7287
groups_at_table = [df_employees.loc[g_idx, 'Anzahl'] * assignment[(g_idx, t_idx)] for g_idx in range(num_groups)]
@@ -76,10 +91,9 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
7691

7792
objective_terms.append(-0.1 * wasted_space)
7893

79-
8094
for attr, weight in constraints_config.items():
8195
if weight == 0:
82-
continue
96+
continue
8397

8498
if attr == 'last neighborhood':
8599
if 'last neighborhood' not in df_employees.columns or 'ProfileID' not in df_employees.columns:
@@ -105,70 +119,165 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
105119
continue
106120
processed_pairs.add(pair)
107121

122+
# IMPROVED: Softer constraint implementation
108123
for t_idx in range(num_tables):
109124
seated_together = model.NewBoolVar(f'seated_together_g{g_idx}_n{neighbor_g_idx}_t{t_idx}')
110125
model.Add(seated_together <= assignment[(g_idx, t_idx)])
111126
model.Add(seated_together <= assignment[(neighbor_g_idx, t_idx)])
112127
model.Add(seated_together >= assignment[(g_idx, t_idx)] + assignment[(neighbor_g_idx, t_idx)] - 1)
113-
objective_terms.append(-weight * seated_together)
114-
128+
129+
# Use absolute weight value to ensure proper direction
130+
objective_terms.append(-abs(weight) * seated_together)
131+
115132
else:
133+
# IMPROVED: Diversity constraints with better handling
116134
if attr not in df_employees.columns:
117135
print(f"Warning: Diversity attribute '{attr}' not found. Skipping.")
118136
continue
119-
137+
138+
# Skip if all values are the same (no diversity possible)
139+
unique_values = df_employees[attr].dropna().unique()
140+
if len(unique_values) <= 1:
141+
print(f"Warning: Attribute '{attr}' has no diversity (only {len(unique_values)} unique values). Skipping.")
142+
continue
143+
120144
print(f"Applying '{attr}' diversity constraint with weight: {weight}")
145+
121146
for t_idx in range(num_tables):
122147
table_id = df_tables.loc[t_idx, 'TableNr']
148+
149+
# IMPROVED: Only consider non-null values for diversity
123150
for value in df_employees[attr].unique():
124-
is_value_present_at_table = model.NewBoolVar(f'attr_{attr}_val_{str(value)}_table_{table_id}')
151+
is_value_present_at_table = model.NewBoolVar(f'attr_{attr}_val_{str(value).replace(" ", "_")}_table_{table_id}')
125152
groups_with_value_indices = df_employees[df_employees[attr] == value].index.tolist()
126153
assignments_for_value_at_table = [assignment[(g_idx, t_idx)] for g_idx in groups_with_value_indices]
127-
154+
128155
if assignments_for_value_at_table:
129156
model.AddMaxEquality(is_value_present_at_table, assignments_for_value_at_table)
157+
objective_terms.append(abs(weight) * is_value_present_at_table)
130158
else:
131159
model.Add(is_value_present_at_table == 0)
132-
133-
objective_terms.append(weight * is_value_present_at_table)
134160

135161
# --- 5. Set the Combined Objective ---
136162
if objective_terms:
137163
model.Maximize(cp_model.LinearExpr.Sum(objective_terms))
164+
print(f"Created {len(objective_terms)} objective terms")
138165
else:
139-
print("Warning: No objective terms were created.")
166+
print("Warning: No objective terms were created. Using basic feasibility.")
140167

141-
# --- 6. Solve the Model ---
168+
# --- 6. IMPROVED: Solve with better parameters ---
142169
solver = cp_model.CpSolver()
143170
solver.parameters.max_time_in_seconds = float(time_limit_sec)
171+
solver.parameters.num_search_workers = search_workers_count # We can use multiple cores
172+
solver.parameters.log_search_progress = True
173+
solver.parameters.cp_model_presolve = True
174+
175+
print("Starting solver...")
144176
status = solver.Solve(model)
145177

146-
# --- 7. Process the Results ---
178+
# --- 7. IMPROVED: Better result processing and debugging ---
179+
print(f"Solver finished with status: {solver.StatusName(status)}")
180+
print(f"Solver statistics:")
181+
print(f" - Conflicts: {solver.NumConflicts()}")
182+
print(f" - Branches: {solver.NumBranches()}")
183+
print(f" - Wall time: {solver.WallTime():.2f}s")
184+
147185
if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
148186
status_name = 'OPTIMAL' if status == cp_model.OPTIMAL else 'FEASIBLE'
149187
print(f"\nSolution found with status: {status_name}")
150188
if objective_terms:
151189
print(f"Total objective score achieved: {solver.ObjectiveValue()}")
152190

191+
# Assign tables to groups
153192
for g_idx in range(num_groups):
154193
for t_idx in range(num_tables):
155194
if solver.Value(assignment[(g_idx, t_idx)]) == 1:
156195
assigned_table_id = df_tables.loc[t_idx, 'TableNr']
157196
group_size = df_employees.loc[g_idx, 'Anzahl']
158-
197+
159198
current_event_assignment = [str(assigned_table_id)] * group_size
160199
employees_data[g_idx]['TableNr'] = current_event_assignment
161200
break
201+
202+
# IMPROVED: Validation of solution
203+
print("\n--- Solution Validation ---")
204+
table_occupancy = {}
205+
for g_idx in range(num_groups):
206+
for t_idx in range(num_tables):
207+
if solver.Value(assignment[(g_idx, t_idx)]) == 1:
208+
table_id = df_tables.loc[t_idx, 'TableNr']
209+
if table_id not in table_occupancy:
210+
table_occupancy[table_id] = 0
211+
table_occupancy[table_id] += df_employees.loc[g_idx, 'Anzahl']
212+
213+
for table_id, occupancy in table_occupancy.items():
214+
table_capacity = df_tables[df_tables['TableNr'] == table_id]['Capacity'].iloc[0]
215+
print(f"Table {table_id}: {occupancy}/{table_capacity} seats")
216+
if occupancy > table_capacity:
217+
print(f"ERROR: Table {table_id} is over capacity!")
218+
219+
return employees_data
220+
221+
elif status == cp_model.INFEASIBLE:
222+
print(f"\nPROBLEM IS INFEASIBLE!")
223+
print("Debugging information:")
224+
print(f"Total people to seat: {total_seats_required}")
225+
print(f"Total capacity: {total_capacity}")
226+
print(f"Largest group: {max_group_size}")
227+
print(f"Largest table: {max_table_capacity}")
228+
229+
# IMPROVED: Try to identify infeasibility source
230+
print("\nTable capacities vs group sizes:")
231+
for idx, row in df_tables.iterrows():
232+
groups_that_fit = sum(1 for _, emp_row in df_employees.iterrows() if emp_row['Anzahl'] <= row['Capacity'])
233+
print(f"Table {row['TableNr']} (capacity {row['Capacity']}): {groups_that_fit} groups can fit")
234+
235+
# Try a relaxed version to help debug
236+
print("\nTrying relaxed model for debugging...")
237+
relaxed_model = cp_model.CpModel()
238+
relaxed_assignment = {}
239+
240+
for g_idx in range(num_groups):
241+
for t_idx in range(num_tables):
242+
relaxed_assignment[(g_idx, t_idx)] = relaxed_model.NewBoolVar(f'assign_g{g_idx}_t{t_idx}')
243+
244+
# Only add basic constraints
245+
for g_idx in range(num_groups):
246+
relaxed_model.AddExactlyOne([relaxed_assignment[(g_idx, t_idx)] for t_idx in range(num_tables)])
247+
248+
# Relaxed capacity constraints (allow slight overflow)
249+
for t_idx in range(num_tables):
250+
table_capacity = df_tables.loc[t_idx, 'Capacity']
251+
groups_at_table = [df_employees.loc[g_idx, 'Anzahl'] * relaxed_assignment[(g_idx, t_idx)] for g_idx in range(num_groups)]
252+
overflow = relaxed_model.NewIntVar(0, max_group_size, f'overflow_t{t_idx}')
253+
relaxed_model.Add(cp_model.LinearExpr.Sum(groups_at_table) <= table_capacity + overflow)
254+
relaxed_model.Minimize(overflow) # Try to minimize overflow
255+
256+
relaxed_solver = cp_model.CpSolver()
257+
relaxed_solver.parameters.max_time_in_seconds = 10.0
258+
relaxed_status = relaxed_solver.Solve(relaxed_model)
259+
260+
if relaxed_status in [cp_model.OPTIMAL, cp_model.FEASIBLE]:
261+
print("Relaxed model found solution - problem likely with soft constraints being too restrictive")
262+
else:
263+
print("Even relaxed model failed - fundamental capacity/assignment issue")
264+
162265
return employees_data
163266
else:
164267
status_name = solver.StatusName(status)
165-
print(f"\nNo solution found. Solver status: {status_name}")
166-
sys.exit(-1)
268+
print(f"\nSolver could not find solution. Status: {status_name}")
269+
if status == cp_model.MODEL_INVALID:
270+
print("Model is invalid - check constraint definitions")
271+
elif status == cp_model.UNKNOWN:
272+
print("Solver timed out or encountered other issues")
167273

168-
# --- Main Execution Block ---
274+
return employees_data
275+
276+
# --- Main Execution Block (unchanged) ---
169277
if __name__ == "__main__":
170-
if len(sys.argv) != 5:
171-
print("Usage: python algo.py <input_json_path> <table_json_path> <config_json_path> <output_json_path>")
278+
if len(sys.argv) != 7:
279+
print("Usage: python algo.py <input_json_path> <table_json_path> <config_json_path> <output_json_path> <num_search_workers> <solver_time_limit>")
280+
print(sys.argv)
172281
sys.exit(1)
173282

174283
# --- Configuration ---
@@ -180,9 +289,8 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
180289
TABLES_JSON_PATH = sys.argv[2]
181290
CONSTRAINTS_JSON_PATH = sys.argv[3]
182291
OUTPUT_JSON_PATH = sys.argv[4]
183-
184-
# Set a time limit for the solver
185-
SOLVER_TIME_LIMIT = 300.0
292+
num_search_workers = int(sys.argv[5])
293+
SOLVER_TIME_LIMIT = int(sys.argv[6])
186294

187295
print("--- Starting Table Assignment from File ---")
188296
try:
@@ -196,10 +304,11 @@ def assign_tables(employees_data, tables_data, constraints_config, time_limit_se
196304
constraints_config = json.load(f)
197305

198306
assigned_employees = assign_tables(
307+
num_search_workers,
199308
employees_data=employee_list,
200309
tables_data=table_list,
201310
constraints_config=constraints_config,
202-
time_limit_sec=SOLVER_TIME_LIMIT
311+
time_limit_sec=SOLVER_TIME_LIMIT,
203312
)
204313

205314
if assigned_employees and all('TableNr' in r for r in assigned_employees):

backend/src/main/java/com/itestra/eep/aspect/GlobalExceptionHandler.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public ResponseEntity<String> handleMaxSizeException(MaxUploadSizeExceededExcept
7878
PastEventUpdateException.class,
7979
EventCapacityExceededException.class,
8080
NotEnoughSeatForSeatAllocationException.class,
81+
NoBigEnoughTableException.class,
8182
InfeasibleSeatAllocationException.class})
8283
public ResponseEntity<Object> handleCustomRuntimeException(RuntimeException exception) {
8384
return ResponseEntity
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.itestra.eep.exceptions;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class NoBigEnoughTableException extends RuntimeException {
7+
8+
private final String message = "Largest employee and visitor group exceeds the largest table's capacity.";
9+
10+
}

backend/src/main/java/com/itestra/eep/exceptions/NotEnoughSeatForSeatAllocationException.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
@Getter
66
public class NotEnoughSeatForSeatAllocationException extends RuntimeException {
77

8-
public NotEnoughSeatForSeatAllocationException(int requiredSeats, int totalAvailableSeats) {
9-
super(String.format("There are not enough seats for every participant: required: %d, available: %d",
10-
requiredSeats, totalAvailableSeats));
11-
}
8+
private final String message = "There are not enough seats for each participant";
129

1310
}

backend/src/main/java/com/itestra/eep/models/Chair.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public class Chair {
1919
@Id
2020
private UUID id;
2121

22-
@ManyToOne(fetch = FetchType.LAZY)
22+
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
2323
@JoinColumn(name = "event_id")
2424
private Event event;
2525

backend/src/main/java/com/itestra/eep/services/impl/SeatAllocationServiceImpl.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.itestra.eep.dtos.constraintSolver.StageMapDTO;
1010
import com.itestra.eep.exceptions.EventNotFoundException;
1111
import com.itestra.eep.exceptions.InfeasibleSeatAllocationException;
12+
import com.itestra.eep.exceptions.NoBigEnoughTableException;
1213
import com.itestra.eep.exceptions.NotEnoughSeatForSeatAllocationException;
1314
import com.itestra.eep.mappers.EmployeeParticipationMapper;
1415
import com.itestra.eep.models.*;
@@ -20,6 +21,7 @@
2021
import lombok.Getter;
2122
import lombok.RequiredArgsConstructor;
2223
import lombok.extern.slf4j.Slf4j;
24+
import org.springframework.beans.factory.annotation.Value;
2325
import org.springframework.stereotype.Service;
2426
import org.springframework.transaction.annotation.Isolation;
2527
import org.springframework.transaction.annotation.Propagation;
@@ -41,6 +43,12 @@
4143
@Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
4244
public class SeatAllocationServiceImpl implements SeatAllocationService {
4345

46+
@Value("${constraint-solver.num-search-workers}")
47+
private String NUM_SEARCH_WORKERS;
48+
49+
@Value("${constraint-solver.solver-time-limit}")
50+
private String SOLVER_TIME_LIMIT;
51+
4452
private final EventRepository eventRepository;
4553
private final ChairRepository chairRepository;
4654
private final ObjectMapper objectMapper;
@@ -132,12 +140,6 @@ public void performTableBasedSeatAllocation(UUID eventId, StageMapDTO stageMap)
132140
Map<UUID, List<UUID>> tablesAndTheirSeats = stageMap.getSeatMap().entrySet().stream()
133141
.collect(Collectors.toMap(Map.Entry::getKey, entry -> new ArrayList<>(entry.getValue().keySet())));
134142

135-
int totalAvailableSeat = tablesAndTheirSeats.values().stream().mapToInt(List::size).sum();
136-
137-
if (event.getParticipantCount() > totalAvailableSeat) {
138-
throw new NotEnoughSeatForSeatAllocationException(event.getParticipantCount(), totalAvailableSeat);
139-
}
140-
141143
// we create input and output temp files
142144
try (TempFileManager tempFiles = new TempFileManager()) {
143145
writeInputFiles(tempFiles, formattedData, stageMap);
@@ -187,18 +189,20 @@ public void performTableBasedSeatAllocation(UUID eventId, StageMapDTO stageMap)
187189
}
188190
}
189191

190-
private static void runPythonScript(TempFileManager tempFiles) throws IOException, InterruptedException {
192+
private void runPythonScript(TempFileManager tempFiles) throws IOException, InterruptedException {
191193
ProcessBuilder pb;
192194

193195
if (Files.notExists(Path.of("../venv/bin/python"))) {
194196
pb = new ProcessBuilder(
195197
"python3",
196-
"/algo(table).py", tempFiles.inputFile.toString(), tempFiles.tableFile.toString(), tempFiles.constraintsFile.toString(), tempFiles.outputFile.toString()
198+
"/algo(table).py", tempFiles.inputFile.toString(), tempFiles.tableFile.toString(), tempFiles.constraintsFile.toString(), tempFiles.outputFile.toString(),
199+
NUM_SEARCH_WORKERS, SOLVER_TIME_LIMIT
197200
);
198201
} else {
199202
pb = new ProcessBuilder(
200203
"../venv/bin/python",
201-
"algo(table).py", tempFiles.inputFile.toString(), tempFiles.tableFile.toString(), tempFiles.constraintsFile.toString(), tempFiles.outputFile.toString()
204+
"algo(table).py", tempFiles.inputFile.toString(), tempFiles.tableFile.toString(), tempFiles.constraintsFile.toString(),
205+
tempFiles.outputFile.toString(), NUM_SEARCH_WORKERS, SOLVER_TIME_LIMIT
202206
);
203207
}
204208

@@ -218,6 +222,8 @@ private static void runPythonScript(TempFileManager tempFiles) throws IOExceptio
218222
int exitCode = process.waitFor();
219223
log.info("Exited with code: {}", exitCode);
220224
if (exitCode == 255) throw new InfeasibleSeatAllocationException();
225+
else if (exitCode == 2) throw new NotEnoughSeatForSeatAllocationException();
226+
else if (exitCode == 3) throw new NoBigEnoughTableException();
221227
}
222228

223229
private void persistNewChairAssignments(UUID eventId, List<Chair> chairsToPersist, Map<UUID, UUID> employeeParticipationToChairMap, Map<UUID, UUID> visitorParticipationToChairMap) {

backend/src/main/java/com/itestra/eep/sql/ddl.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ CREATE TABLE organization.previous_matches
7070
CREATE TABLE organization.chair
7171
(
7272
id UUID PRIMARY KEY,
73-
event_id UUID REFERENCES organization.event (id)
73+
event_id UUID REFERENCES organization.event (id) ON DELETE CASCADE
7474
);
7575

7676
CREATE TABLE organization.employee_participation

0 commit comments

Comments
 (0)