KindAlien commited on
Commit
17dea99
·
verified ·
1 Parent(s): 275ea78

Update objective_engine.py

Browse files
Files changed (1) hide show
  1. objective_engine.py +175 -301
objective_engine.py CHANGED
@@ -1,31 +1,13 @@
1
  # objective_engine.py
2
 
3
- """
4
- This module implements the ObjectiveEngine for the VTU Automated Timetable Generator.
5
- It handles ONLY SOFT CONSTRAINTS by adding weighted penalties to the solver's objective function.
6
-
7
- Responsibilities:
8
- - Define penalties for undesirable schedules (e.g., gaps, late core classes).
9
- - Create auxiliary variables to calculate complex metrics (like daily span).
10
- - Sum all weighted penalties and set the Minimization objective.
11
-
12
- This module is optional and pluggable. It does not enforce hard rules.
13
- """
14
-
15
- from typing import List, Dict
16
- from ortools.sat.python import cp_model
17
  from collections import defaultdict
18
-
19
- # Import project-specific modules
20
- from models import Task, Faculty, Section, Room, SubjectType
21
- import constants as const
22
- # Type hint for the ConstraintEngine
23
  from constraint_engine import ConstraintEngine
 
24
 
25
  class ObjectiveEngine:
26
- """
27
- Manages soft constraints and the optimization objective.
28
- """
29
  def __init__(
30
  self,
31
  model: cp_model.CpModel,
@@ -33,20 +15,15 @@ class ObjectiveEngine:
33
  tasks: List[Task],
34
  faculties: List[Faculty],
35
  sections: List[Section],
36
- rooms: List[Room],
37
  weights: Dict[str, int] = None
38
  ):
39
- """
40
- Initializes the ObjectiveEngine.
41
- """
42
  self.model = model
43
  self.ce = constraint_engine
44
  self.tasks = tasks
45
  self.faculties = faculties
46
  self.sections = sections
47
- self.rooms = rooms
48
-
49
- # Default weights if none provided
50
  self.weights = weights or {
51
  "subject_repetition": 10,
52
  "morning_core": 5,
@@ -56,12 +33,33 @@ class ObjectiveEngine:
56
  "isolated_afternoon": 100,
57
  "campus_movement": 3,
58
  "faculty_load_balance": 1,
59
- "no_first_hour_free": 0, # Disabled intentionally
60
  "pack_morning": 50,
61
  "avoid_late_afternoon": 40
62
  }
63
 
64
  self.penalties: List[cp_model.IntVar] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  def build_objective(self):
67
  """
@@ -90,14 +88,9 @@ class ObjectiveEngine:
90
  self.model.Minimize(0)
91
 
92
  def _minimize_subject_repetition(self):
93
- """
94
- Penalizes scheduling the same theory subject multiple times on the same day
95
- for a specific section.
96
- """
97
  weight = self.weights.get("subject_repetition", 0)
98
  if weight == 0: return
99
 
100
- # Group tasks by (section, subject)
101
  tasks_by_sec_sub = {}
102
  for task in self.tasks:
103
  if task.subject.subject_type == SubjectType.THEORY:
@@ -107,52 +100,32 @@ class ObjectiveEngine:
107
  tasks_by_sec_sub[key].append(task)
108
 
109
  for (sec_id, sub_code), subject_tasks in tasks_by_sec_sub.items():
110
- if len(subject_tasks) < 2:
111
- continue
112
 
113
- # Compare every pair
114
  for i in range(len(subject_tasks)):
115
  for j in range(i + 1, len(subject_tasks)):
116
  t1 = subject_tasks[i]
117
  t2 = subject_tasks[j]
118
 
119
- start_var_1 = self.ce.task_vars[t1.task_id][0]
120
- start_var_2 = self.ce.task_vars[t2.task_id][0]
121
-
122
- # Create variables representing the day index (0-4)
123
- day_1 = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"day_{t1.task_id}")
124
- day_2 = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"day_{t2.task_id}")
125
 
126
- # Helper: day = start_slot // slots_per_day
127
- self.model.AddDivisionEquality(day_1, start_var_1, const.NUM_TEACHING_SLOTS_PER_DAY)
128
- self.model.AddDivisionEquality(day_2, start_var_2, const.NUM_TEACHING_SLOTS_PER_DAY)
129
-
130
- # Reify: are they on the same day?
131
  same_day = self.model.NewBoolVar(f"same_day_{t1.task_id}_{t2.task_id}")
132
  self.model.Add(day_1 == day_2).OnlyEnforceIf(same_day)
133
  self.model.Add(day_1 != day_2).OnlyEnforceIf(same_day.Not())
134
 
135
- # Add penalty
136
  self.penalties.append(same_day * weight)
137
 
138
  def _prioritize_morning_core_subjects(self):
139
- """
140
- Penalizes Core subjects if they are scheduled after the lunch break.
141
- """
142
  weight = self.weights.get("morning_core", 0)
143
  if weight == 0: return
144
 
145
- # Assume slots 0-3 are morning, 4-7 are afternoon
146
  afternoon_start_index = 4
147
 
148
  for task in self.tasks:
149
  if task.subject.is_core and task.subject.subject_type == SubjectType.THEORY:
150
- start_var = self.ce.task_vars[task.task_id][0]
151
-
152
- daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_{task.task_id}")
153
- self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
154
 
155
- # Penalty if daily_slot >= afternoon_start_index
156
  is_afternoon = self.model.NewBoolVar(f"is_afternoon_{task.task_id}")
157
  self.model.Add(daily_slot >= afternoon_start_index).OnlyEnforceIf(is_afternoon)
158
  self.model.Add(daily_slot < afternoon_start_index).OnlyEnforceIf(is_afternoon.Not())
@@ -160,9 +133,6 @@ class ObjectiveEngine:
160
  self.penalties.append(is_afternoon * weight)
161
 
162
  def _avoid_late_heavy_subjects(self):
163
- """
164
- Penalizes Heavy subjects if they are scheduled in the very last slot of the day.
165
- """
166
  weight = self.weights.get("late_heavy", 0)
167
  if weight == 0: return
168
 
@@ -170,11 +140,8 @@ class ObjectiveEngine:
170
 
171
  for task in self.tasks:
172
  if task.subject.is_heavy:
173
- start_var = self.ce.task_vars[task.task_id][0]
174
 
175
- daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_heavy_{task.task_id}")
176
- self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
177
-
178
  is_last_slot = self.model.NewBoolVar(f"is_last_slot_{task.task_id}")
179
  self.model.Add(daily_slot == last_slot_index).OnlyEnforceIf(is_last_slot)
180
  self.model.Add(daily_slot != last_slot_index).OnlyEnforceIf(is_last_slot.Not())
@@ -198,52 +165,37 @@ class ObjectiveEngine:
198
  if not f_tasks: continue
199
 
200
  for day in range(const.NUM_WORKING_DAYS):
201
- slot_occupied = []
202
- for s in range(const.NUM_TEACHING_SLOTS_PER_DAY):
203
- abs_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY + s
 
 
 
 
 
 
 
 
 
204
 
205
- task_covers = []
206
- for task in f_tasks:
207
- covers = self.model.NewBoolVar(f"fac_{faculty_id}_covers_{abs_slot}_{task.task_id}")
208
- start_var = self.ce.task_vars[task.task_id][0]
209
- min_start = max(abs_slot - task.duration + 1, day * const.NUM_TEACHING_SLOTS_PER_DAY)
210
- max_start = abs_slot
211
-
212
- eq_lits = []
213
- for p in range(min_start, max_start + 1):
214
- is_p = self.model.NewBoolVar(f"fac_{faculty_id}_t_{task.task_id}_s_{p}")
215
- self.model.Add(start_var == p).OnlyEnforceIf(is_p)
216
- self.model.Add(start_var != p).OnlyEnforceIf(is_p.Not())
217
- eq_lits.append(is_p)
218
-
219
- self.model.AddBoolOr(eq_lits).OnlyEnforceIf(covers)
220
- for lit in eq_lits:
221
- self.model.AddImplication(covers.Not(), lit.Not())
222
-
223
- task_covers.append(covers)
224
-
225
- is_occupied = self.model.NewBoolVar(f"fac_{faculty_id}_occ_{abs_slot}")
226
- self.model.AddBoolOr(task_covers).OnlyEnforceIf(is_occupied)
227
- for lit in task_covers:
228
- self.model.AddImplication(is_occupied.Not(), lit.Not())
229
-
230
- slot_occupied.append(is_occupied)
231
 
232
- for s in range(1, const.NUM_TEACHING_SLOTS_PER_DAY - 1):
233
- has_before = self.model.NewBoolVar(f"fac_{faculty_id}_before_{day}_{s}")
234
- self.model.AddBoolOr(slot_occupied[:s]).OnlyEnforceIf(has_before)
235
- for lit in slot_occupied[:s]:
236
- self.model.AddImplication(has_before.Not(), lit.Not())
237
-
238
- has_after = self.model.NewBoolVar(f"fac_{faculty_id}_after_{day}_{s}")
239
- self.model.AddBoolOr(slot_occupied[s+1:]).OnlyEnforceIf(has_after)
240
- for lit in slot_occupied[s+1:]:
241
- self.model.AddImplication(has_after.Not(), lit.Not())
242
-
243
- is_gap = self.model.NewBoolVar(f"fac_{faculty_id}_is_gap_{day}_{s}")
244
- self.model.AddBoolAnd([slot_occupied[s].Not(), has_before, has_after]).OnlyEnforceIf(is_gap)
245
 
246
- self.penalties.append(is_gap * weight)
 
 
 
 
 
 
 
247
 
248
  def _minimize_student_gaps(self):
249
  weight = self.weights.get("student_gaps", 100)
@@ -257,62 +209,43 @@ class ObjectiveEngine:
257
  if not s_tasks: continue
258
 
259
  for day in range(const.NUM_WORKING_DAYS):
260
- slot_occupied = []
261
- for s in range(const.NUM_TEACHING_SLOTS_PER_DAY):
262
- abs_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY + s
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
- task_covers = []
265
- for task in s_tasks:
266
- covers = self.model.NewBoolVar(f"sec_{sec_id}_covers_{abs_slot}_{task.task_id}")
267
- start_var = self.ce.task_vars[task.task_id][0]
268
- min_start = max(abs_slot - task.duration + 1, day * const.NUM_TEACHING_SLOTS_PER_DAY)
269
- max_start = abs_slot
270
-
271
- eq_lits = []
272
- for p in range(min_start, max_start + 1):
273
- is_p = self.model.NewBoolVar(f"sec_{sec_id}_t_{task.task_id}_s_{p}")
274
- self.model.Add(start_var == p).OnlyEnforceIf(is_p)
275
- self.model.Add(start_var != p).OnlyEnforceIf(is_p.Not())
276
- eq_lits.append(is_p)
277
-
278
- self.model.AddBoolOr(eq_lits).OnlyEnforceIf(covers)
279
- for lit in eq_lits:
280
- self.model.AddImplication(covers.Not(), lit.Not())
281
-
282
- task_covers.append(covers)
283
-
284
- is_occupied = self.model.NewBoolVar(f"sec_{sec_id}_occ_{abs_slot}")
285
- self.model.AddBoolOr(task_covers).OnlyEnforceIf(is_occupied)
286
- for lit in task_covers:
287
- self.model.AddImplication(is_occupied.Not(), lit.Not())
288
-
289
- slot_occupied.append(is_occupied)
290
 
291
- for s in range(1, const.NUM_TEACHING_SLOTS_PER_DAY - 1):
292
- has_before = self.model.NewBoolVar(f"sec_{sec_id}_before_{day}_{s}")
293
- self.model.AddBoolOr(slot_occupied[:s]).OnlyEnforceIf(has_before)
294
- for lit in slot_occupied[:s]:
295
- self.model.AddImplication(has_before.Not(), lit.Not())
296
-
297
- has_after = self.model.NewBoolVar(f"sec_{sec_id}_after_{day}_{s}")
298
- self.model.AddBoolOr(slot_occupied[s+1:]).OnlyEnforceIf(has_after)
299
- for lit in slot_occupied[s+1:]:
300
- self.model.AddImplication(has_after.Not(), lit.Not())
301
-
302
- is_gap = self.model.NewBoolVar(f"sec_{sec_id}_is_gap_{day}_{s}")
303
- self.model.AddBoolAnd([slot_occupied[s].Not(), has_before, has_after]).OnlyEnforceIf(is_gap)
304
 
305
- self.penalties.append(is_gap * weight)
 
 
 
 
 
306
 
307
  def _penalize_isolated_afternoon_classes(self):
308
- """
309
- Penalizes sections having only 1 or 2 classes (slots) after lunch.
310
- Students would rather have either no afternoon classes or a full afternoon.
311
- """
312
  weight = self.weights.get("isolated_afternoon", 10)
313
  if weight == 0: return
314
 
315
- afternoon_start_index = 4 # Index for slots after lunch
316
 
317
  tasks_by_section = defaultdict(list)
318
  for task in self.tasks:
@@ -323,13 +256,8 @@ class ObjectiveEngine:
323
  afternoon_duration_sum = []
324
 
325
  for task in s_tasks:
326
- start_var = self.ce.task_vars[task.task_id][0]
327
-
328
- daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_{task.task_id}_{day}")
329
- self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
330
-
331
- t_day = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"t_day_{task.task_id}_aft_{day}")
332
- self.model.AddDivisionEquality(t_day, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
333
 
334
  is_on_day = self.model.NewBoolVar(f"is_on_day_{task.task_id}_{day}_aft")
335
  self.model.Add(t_day == day).OnlyEnforceIf(is_on_day)
@@ -339,7 +267,6 @@ class ObjectiveEngine:
339
  self.model.Add(daily_slot >= afternoon_start_index).OnlyEnforceIf(is_afternoon)
340
  self.model.Add(daily_slot < afternoon_start_index).OnlyEnforceIf(is_afternoon.Not())
341
 
342
- # Task is on this day AND in the afternoon
343
  is_on_day_and_afternoon = self.model.NewBoolVar(f"is_on_day_and_afternoon_{task.task_id}_{day}")
344
  self.model.AddBoolAnd([is_on_day, is_afternoon]).OnlyEnforceIf(is_on_day_and_afternoon)
345
 
@@ -348,88 +275,73 @@ class ObjectiveEngine:
348
  self.model.Add(dur_term == 0).OnlyEnforceIf(is_on_day_and_afternoon.Not())
349
 
350
  afternoon_duration_sum.append(dur_term)
351
-
352
- total_afternoon_dur = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"tot_aft_dur_{sec_id}_{day}")
353
- if afternoon_duration_sum:
354
- self.model.Add(total_afternoon_dur == sum(afternoon_duration_sum))
355
- else:
356
- self.model.Add(total_afternoon_dur == 0)
357
-
358
- # We want to penalize if total_afternoon_dur is 1 or 2.
359
- is_dur_1 = self.model.NewBoolVar(f"is_dur_1_{sec_id}_{day}")
360
- self.model.Add(total_afternoon_dur == 1).OnlyEnforceIf(is_dur_1)
361
- self.model.Add(total_afternoon_dur != 1).OnlyEnforceIf(is_dur_1.Not())
362
-
363
- is_dur_2 = self.model.NewBoolVar(f"is_dur_2_{sec_id}_{day}")
364
- self.model.Add(total_afternoon_dur == 2).OnlyEnforceIf(is_dur_2)
365
- self.model.Add(total_afternoon_dur != 2).OnlyEnforceIf(is_dur_2.Not())
366
-
367
- is_isolated = self.model.NewBoolVar(f"is_isolated_{sec_id}_{day}")
368
- self.model.AddBoolOr([is_dur_1, is_dur_2]).OnlyEnforceIf(is_isolated)
369
- self.model.AddBoolAnd([is_dur_1.Not(), is_dur_2.Not()]).OnlyEnforceIf(is_isolated.Not())
370
 
371
  self.penalties.append(is_isolated * weight)
372
 
373
  def _minimize_campus_movement(self):
374
- """
375
- Penalizes consecutive tasks for a section that are in different buildings.
376
- """
377
- weight = self.weights.get("campus_movement", 0)
378
  if weight == 0: return
379
-
380
- unique_buildings = sorted(list(set(r.building for r in self.rooms)))
381
- building_to_id = {b: i for i, b in enumerate(unique_buildings)}
382
- room_idx_to_building_id = [building_to_id[r.building] for r in self.rooms]
383
 
384
  tasks_by_section = defaultdict(list)
385
  for task in self.tasks:
386
  tasks_by_section[task.section.section_id].append(task)
387
-
388
- for sec_id, sec_tasks in tasks_by_section.items():
389
- if len(sec_tasks) < 2: continue
390
 
391
- for i in range(len(sec_tasks)):
392
- for j in range(len(sec_tasks)):
393
- if i == j: continue
394
- t1 = sec_tasks[i]
395
- t2 = sec_tasks[j]
396
-
397
- t1_end = self.ce.task_vars[t1.task_id][1]
398
- t2_start = self.ce.task_vars[t2.task_id][0]
399
 
400
- is_consecutive = self.model.NewBoolVar(f"consec_{t1.task_id}_{t2.task_id}")
401
- self.model.Add(t1_end == t2_start).OnlyEnforceIf(is_consecutive)
402
- self.model.Add(t1_end != t2_start).OnlyEnforceIf(is_consecutive.Not())
403
-
404
- b1_var = self.model.NewIntVar(0, len(unique_buildings), f"bld_{t1.task_id}")
405
- b2_var = self.model.NewIntVar(0, len(unique_buildings), f"bld_{t2.task_id}")
406
 
407
- room_var_1 = self.ce.task_vars[t1.task_id][3]
408
- room_var_2 = self.ce.task_vars[t2.task_id][3]
409
-
410
- self.model.AddElement(room_var_1, room_idx_to_building_id, b1_var)
411
- self.model.AddElement(room_var_2, room_idx_to_building_id, b2_var)
412
-
413
- diff_building = self.model.NewBoolVar(f"diff_bld_{t1.task_id}_{t2.task_id}")
414
- self.model.Add(b1_var != b2_var).OnlyEnforceIf(diff_building)
415
- self.model.Add(b1_var == b2_var).OnlyEnforceIf(diff_building.Not())
416
-
417
- penalty_active = self.model.NewBoolVar(f"move_pen_{t1.task_id}_{t2.task_id}")
418
- self.model.AddBoolAnd([is_consecutive, diff_building]).OnlyEnforceIf(penalty_active)
419
 
420
- self.penalties.append(penalty_active * weight)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
  def _penalize_first_hour_free(self):
423
- # DISABLED: This constraint forces the solver to place classes at 8:45 every day to avoid a penalty.
424
- # Since sections don't always have enough classes to fill all 5 days completely, forcing a class
425
- # at the start of every day causes the remaining classes to be scattered, leading to unavoidable gaps!
426
  pass
427
 
428
  def _penalize_empty_morning_slots(self):
429
- """
430
- Penalizes any empty slot in the first 4 hours (slots 0, 1, 2, 3) for students.
431
- This forces the solver to pack classes tightly into the morning.
432
- """
433
  weight = self.weights.get("pack_morning", 50)
434
  if weight == 0: return
435
 
@@ -440,81 +352,43 @@ class ObjectiveEngine:
440
  for sec_id, s_tasks in tasks_by_section.items():
441
  if not s_tasks: continue
442
 
443
- for day in range(const.NUM_WORKING_DAYS):
444
- for s in range(4): # 0, 1, 2, 3
445
- abs_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY + s
446
-
447
- task_covers = []
448
- for task in s_tasks:
449
- covers = self.model.NewBoolVar(f"sec_{sec_id}_m_covers_{abs_slot}_{task.task_id}")
450
- start_var = self.ce.task_vars[task.task_id][0]
451
- min_start = max(abs_slot - task.duration + 1, day * const.NUM_TEACHING_SLOTS_PER_DAY)
452
- max_start = abs_slot
453
-
454
- eq_lits = []
455
- for p in range(min_start, max_start + 1):
456
- is_p = self.model.NewBoolVar(f"sec_{sec_id}_m_t_{task.task_id}_s_{p}")
457
- self.model.Add(start_var == p).OnlyEnforceIf(is_p)
458
- self.model.Add(start_var != p).OnlyEnforceIf(is_p.Not())
459
- eq_lits.append(is_p)
460
-
461
- self.model.AddBoolOr(eq_lits).OnlyEnforceIf(covers)
462
- for lit in eq_lits:
463
- self.model.AddImplication(covers.Not(), lit.Not())
464
-
465
- task_covers.append(covers)
466
-
467
- is_occupied = self.model.NewBoolVar(f"sec_{sec_id}_m_occ_{abs_slot}")
468
- self.model.AddBoolOr(task_covers).OnlyEnforceIf(is_occupied)
469
- for lit in task_covers:
470
- self.model.AddImplication(is_occupied.Not(), lit.Not())
471
-
472
- # Penalize if NOT occupied
473
- self.penalties.append(is_occupied.Not() * weight)
474
 
475
  def _penalize_late_afternoon_slots(self):
476
- """
477
- Penalizes any class scheduled in the last 2 slots of the day (slots 6, 7).
478
- This encourages classes to end by 3:30 PM (after slot 5).
479
- """
480
  weight = self.weights.get("avoid_late_afternoon", 40)
481
  if weight == 0: return
482
 
483
- tasks_by_section = defaultdict(list)
484
  for task in self.tasks:
485
- tasks_by_section[task.section.section_id].append(task)
486
-
487
- for sec_id, s_tasks in tasks_by_section.items():
488
- if not s_tasks: continue
489
-
490
- for day in range(const.NUM_WORKING_DAYS):
491
- for s in range(6, const.NUM_TEACHING_SLOTS_PER_DAY): # 6, 7
492
- abs_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY + s
493
-
494
- task_covers = []
495
- for task in s_tasks:
496
- covers = self.model.NewBoolVar(f"sec_{sec_id}_la_covers_{abs_slot}_{task.task_id}")
497
- start_var = self.ce.task_vars[task.task_id][0]
498
- min_start = max(abs_slot - task.duration + 1, day * const.NUM_TEACHING_SLOTS_PER_DAY)
499
- max_start = abs_slot
500
-
501
- eq_lits = []
502
- for p in range(min_start, max_start + 1):
503
- is_p = self.model.NewBoolVar(f"sec_{sec_id}_la_t_{task.task_id}_s_{p}")
504
- self.model.Add(start_var == p).OnlyEnforceIf(is_p)
505
- self.model.Add(start_var != p).OnlyEnforceIf(is_p.Not())
506
- eq_lits.append(is_p)
507
-
508
- self.model.AddBoolOr(eq_lits).OnlyEnforceIf(covers)
509
- for lit in eq_lits:
510
- self.model.AddImplication(covers.Not(), lit.Not())
511
-
512
- task_covers.append(covers)
513
-
514
- is_occupied = self.model.NewBoolVar(f"sec_{sec_id}_la_occ_{abs_slot}")
515
- self.model.AddBoolOr(task_covers).OnlyEnforceIf(is_occupied)
516
- for lit in task_covers:
517
- self.model.AddImplication(is_occupied.Not(), lit.Not())
518
-
519
- # Penalize if OCCUPIED
520
- self.penalties.append(is_occupied * weight)
 
1
  # objective_engine.py
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from collections import defaultdict
4
+ from typing import List, Dict, Any
5
+ from ortools.sat.python import cp_model
6
+ from models import Task, Faculty, Section, SubjectType
 
 
7
  from constraint_engine import ConstraintEngine
8
+ import constants as const
9
 
10
  class ObjectiveEngine:
 
 
 
11
  def __init__(
12
  self,
13
  model: cp_model.CpModel,
 
15
  tasks: List[Task],
16
  faculties: List[Faculty],
17
  sections: List[Section],
18
+ rooms: List[Any],
19
  weights: Dict[str, int] = None
20
  ):
 
 
 
21
  self.model = model
22
  self.ce = constraint_engine
23
  self.tasks = tasks
24
  self.faculties = faculties
25
  self.sections = sections
26
+
 
 
27
  self.weights = weights or {
28
  "subject_repetition": 10,
29
  "morning_core": 5,
 
33
  "isolated_afternoon": 100,
34
  "campus_movement": 3,
35
  "faculty_load_balance": 1,
36
+ "no_first_hour_free": 0,
37
  "pack_morning": 50,
38
  "avoid_late_afternoon": 40
39
  }
40
 
41
  self.penalties: List[cp_model.IntVar] = []
42
+
43
+ # Precompute common variables for extreme efficiency
44
+ self.t_day = {}
45
+ self.t_dstart = {}
46
+ self.t_dend = {}
47
+
48
+ for task in self.tasks:
49
+ start_var = self.ce.task_vars[task.task_id][0]
50
+
51
+ day = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"t_{task.task_id}_day")
52
+ self.model.AddDivisionEquality(day, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
53
+ self.t_day[task.task_id] = day
54
+
55
+ d_start = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"t_{task.task_id}_dstart")
56
+ self.model.AddModuloEquality(d_start, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
57
+ self.t_dstart[task.task_id] = d_start
58
+
59
+ # Note: End slot could reach NUM_TEACHING_SLOTS_PER_DAY (which means it ends at the very end of the day)
60
+ d_end = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"t_{task.task_id}_dend")
61
+ self.model.Add(d_end == d_start + task.duration)
62
+ self.t_dend[task.task_id] = d_end
63
 
64
  def build_objective(self):
65
  """
 
88
  self.model.Minimize(0)
89
 
90
  def _minimize_subject_repetition(self):
 
 
 
 
91
  weight = self.weights.get("subject_repetition", 0)
92
  if weight == 0: return
93
 
 
94
  tasks_by_sec_sub = {}
95
  for task in self.tasks:
96
  if task.subject.subject_type == SubjectType.THEORY:
 
100
  tasks_by_sec_sub[key].append(task)
101
 
102
  for (sec_id, sub_code), subject_tasks in tasks_by_sec_sub.items():
103
+ if len(subject_tasks) < 2: continue
 
104
 
 
105
  for i in range(len(subject_tasks)):
106
  for j in range(i + 1, len(subject_tasks)):
107
  t1 = subject_tasks[i]
108
  t2 = subject_tasks[j]
109
 
110
+ day_1 = self.t_day[t1.task_id]
111
+ day_2 = self.t_day[t2.task_id]
 
 
 
 
112
 
 
 
 
 
 
113
  same_day = self.model.NewBoolVar(f"same_day_{t1.task_id}_{t2.task_id}")
114
  self.model.Add(day_1 == day_2).OnlyEnforceIf(same_day)
115
  self.model.Add(day_1 != day_2).OnlyEnforceIf(same_day.Not())
116
 
 
117
  self.penalties.append(same_day * weight)
118
 
119
  def _prioritize_morning_core_subjects(self):
 
 
 
120
  weight = self.weights.get("morning_core", 0)
121
  if weight == 0: return
122
 
 
123
  afternoon_start_index = 4
124
 
125
  for task in self.tasks:
126
  if task.subject.is_core and task.subject.subject_type == SubjectType.THEORY:
127
+ daily_slot = self.t_dstart[task.task_id]
 
 
 
128
 
 
129
  is_afternoon = self.model.NewBoolVar(f"is_afternoon_{task.task_id}")
130
  self.model.Add(daily_slot >= afternoon_start_index).OnlyEnforceIf(is_afternoon)
131
  self.model.Add(daily_slot < afternoon_start_index).OnlyEnforceIf(is_afternoon.Not())
 
133
  self.penalties.append(is_afternoon * weight)
134
 
135
  def _avoid_late_heavy_subjects(self):
 
 
 
136
  weight = self.weights.get("late_heavy", 0)
137
  if weight == 0: return
138
 
 
140
 
141
  for task in self.tasks:
142
  if task.subject.is_heavy:
143
+ daily_slot = self.t_dstart[task.task_id]
144
 
 
 
 
145
  is_last_slot = self.model.NewBoolVar(f"is_last_slot_{task.task_id}")
146
  self.model.Add(daily_slot == last_slot_index).OnlyEnforceIf(is_last_slot)
147
  self.model.Add(daily_slot != last_slot_index).OnlyEnforceIf(is_last_slot.Not())
 
165
  if not f_tasks: continue
166
 
167
  for day in range(const.NUM_WORKING_DAYS):
168
+ min_start_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"fac_{faculty_id}_min_s_d{day}")
169
+ max_end_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"fac_{faculty_id}_max_e_d{day}")
170
+
171
+ start_vars = [const.NUM_TEACHING_SLOTS_PER_DAY]
172
+ end_vars = [0]
173
+ total_duration = 0
174
+
175
+ for task in f_tasks:
176
+ is_on_day = self.model.NewBoolVar(f"fac_{faculty_id}_on_d{day}_t{task.task_id}")
177
+ t_day = self.t_day[task.task_id]
178
+ self.model.Add(t_day == day).OnlyEnforceIf(is_on_day)
179
+ self.model.Add(t_day != day).OnlyEnforceIf(is_on_day.Not())
180
 
181
+ start_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, "")
182
+ self.model.Add(start_on_day == self.t_dstart[task.task_id]).OnlyEnforceIf(is_on_day)
183
+ self.model.Add(start_on_day == const.NUM_TEACHING_SLOTS_PER_DAY).OnlyEnforceIf(is_on_day.Not())
184
+ start_vars.append(start_on_day)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
+ end_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, "")
187
+ self.model.Add(end_on_day == self.t_dend[task.task_id]).OnlyEnforceIf(is_on_day)
188
+ self.model.Add(end_on_day == 0).OnlyEnforceIf(is_on_day.Not())
189
+ end_vars.append(end_on_day)
 
 
 
 
 
 
 
 
 
190
 
191
+ total_duration += task.duration * is_on_day
192
+
193
+ self.model.AddMinEquality(min_start_on_day, start_vars)
194
+ self.model.AddMaxEquality(max_end_on_day, end_vars)
195
+
196
+ gap = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"fac_{faculty_id}_gap_d{day}")
197
+ self.model.AddMaxEquality(gap, [0, max_end_on_day - min_start_on_day - total_duration])
198
+ self.penalties.append(gap * weight)
199
 
200
  def _minimize_student_gaps(self):
201
  weight = self.weights.get("student_gaps", 100)
 
209
  if not s_tasks: continue
210
 
211
  for day in range(const.NUM_WORKING_DAYS):
212
+ min_start_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"sec_{sec_id}_min_s_d{day}")
213
+ max_end_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"sec_{sec_id}_max_e_d{day}")
214
+
215
+ start_vars = [const.NUM_TEACHING_SLOTS_PER_DAY]
216
+ end_vars = [0]
217
+ total_duration = 0
218
+
219
+ for task in s_tasks:
220
+ is_on_day = self.model.NewBoolVar(f"sec_{sec_id}_on_d{day}_t{task.task_id}")
221
+ t_day = self.t_day[task.task_id]
222
+ self.model.Add(t_day == day).OnlyEnforceIf(is_on_day)
223
+ self.model.Add(t_day != day).OnlyEnforceIf(is_on_day.Not())
224
+
225
+ start_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, "")
226
+ self.model.Add(start_on_day == self.t_dstart[task.task_id]).OnlyEnforceIf(is_on_day)
227
+ self.model.Add(start_on_day == const.NUM_TEACHING_SLOTS_PER_DAY).OnlyEnforceIf(is_on_day.Not())
228
+ start_vars.append(start_on_day)
229
 
230
+ end_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, "")
231
+ self.model.Add(end_on_day == self.t_dend[task.task_id]).OnlyEnforceIf(is_on_day)
232
+ self.model.Add(end_on_day == 0).OnlyEnforceIf(is_on_day.Not())
233
+ end_vars.append(end_on_day)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
+ total_duration += task.duration * is_on_day
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
+ self.model.AddMinEquality(min_start_on_day, start_vars)
238
+ self.model.AddMaxEquality(max_end_on_day, end_vars)
239
+
240
+ gap = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"sec_{sec_id}_gap_d{day}")
241
+ self.model.AddMaxEquality(gap, [0, max_end_on_day - min_start_on_day - total_duration])
242
+ self.penalties.append(gap * weight)
243
 
244
  def _penalize_isolated_afternoon_classes(self):
 
 
 
 
245
  weight = self.weights.get("isolated_afternoon", 10)
246
  if weight == 0: return
247
 
248
+ afternoon_start_index = 4
249
 
250
  tasks_by_section = defaultdict(list)
251
  for task in self.tasks:
 
256
  afternoon_duration_sum = []
257
 
258
  for task in s_tasks:
259
+ daily_slot = self.t_dstart[task.task_id]
260
+ t_day = self.t_day[task.task_id]
 
 
 
 
 
261
 
262
  is_on_day = self.model.NewBoolVar(f"is_on_day_{task.task_id}_{day}_aft")
263
  self.model.Add(t_day == day).OnlyEnforceIf(is_on_day)
 
267
  self.model.Add(daily_slot >= afternoon_start_index).OnlyEnforceIf(is_afternoon)
268
  self.model.Add(daily_slot < afternoon_start_index).OnlyEnforceIf(is_afternoon.Not())
269
 
 
270
  is_on_day_and_afternoon = self.model.NewBoolVar(f"is_on_day_and_afternoon_{task.task_id}_{day}")
271
  self.model.AddBoolAnd([is_on_day, is_afternoon]).OnlyEnforceIf(is_on_day_and_afternoon)
272
 
 
275
  self.model.Add(dur_term == 0).OnlyEnforceIf(is_on_day_and_afternoon.Not())
276
 
277
  afternoon_duration_sum.append(dur_term)
278
+
279
+ total_aft_duration = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"total_aft_dur_sec{sec_id}_d{day}")
280
+ self.model.Add(total_aft_duration == sum(afternoon_duration_sum))
281
+
282
+ is_isolated = self.model.NewBoolVar(f"is_isolated_aft_sec{sec_id}_d{day}")
283
+
284
+ is_gt_0 = self.model.NewBoolVar(f"aft_gt_0_sec{sec_id}_d{day}")
285
+ self.model.Add(total_aft_duration > 0).OnlyEnforceIf(is_gt_0)
286
+ self.model.Add(total_aft_duration == 0).OnlyEnforceIf(is_gt_0.Not())
287
+
288
+ is_le_2 = self.model.NewBoolVar(f"aft_le_2_sec{sec_id}_d{day}")
289
+ self.model.Add(total_aft_duration <= 2).OnlyEnforceIf(is_le_2)
290
+ self.model.Add(total_aft_duration > 2).OnlyEnforceIf(is_le_2.Not())
291
+
292
+ self.model.AddBoolAnd([is_gt_0, is_le_2]).OnlyEnforceIf(is_isolated)
 
 
 
 
293
 
294
  self.penalties.append(is_isolated * weight)
295
 
296
  def _minimize_campus_movement(self):
297
+ weight = self.weights.get("campus_movement", 3)
 
 
 
298
  if weight == 0: return
 
 
 
 
299
 
300
  tasks_by_section = defaultdict(list)
301
  for task in self.tasks:
302
  tasks_by_section[task.section.section_id].append(task)
 
 
 
303
 
304
+ for sec_id, s_tasks in tasks_by_section.items():
305
+ if len(s_tasks) < 2: continue
306
+
307
+ for i in range(len(s_tasks)):
308
+ for j in range(i + 1, len(s_tasks)):
309
+ t1 = s_tasks[i]
310
+ t2 = s_tasks[j]
 
311
 
312
+ start1 = self.ce.task_vars[t1.task_id][0]
313
+ end1 = self.ce.task_vars[t1.task_id][1]
314
+ room1 = self.ce.task_vars[t1.task_id][3]
 
 
 
315
 
316
+ start2 = self.ce.task_vars[t2.task_id][0]
317
+ end2 = self.ce.task_vars[t2.task_id][1]
318
+ room2 = self.ce.task_vars[t2.task_id][3]
319
+
320
+ is_consecutive_1_2 = self.model.NewBoolVar(f"cons_{t1.task_id}_{t2.task_id}")
321
+ self.model.Add(end1 == start2).OnlyEnforceIf(is_consecutive_1_2)
322
+ self.model.Add(end1 != start2).OnlyEnforceIf(is_consecutive_1_2.Not())
 
 
 
 
 
323
 
324
+ is_consecutive_2_1 = self.model.NewBoolVar(f"cons_{t2.task_id}_{t1.task_id}")
325
+ self.model.Add(end2 == start1).OnlyEnforceIf(is_consecutive_2_1)
326
+ self.model.Add(end2 != start1).OnlyEnforceIf(is_consecutive_2_1.Not())
327
+
328
+ are_consecutive = self.model.NewBoolVar(f"are_cons_{t1.task_id}_{t2.task_id}")
329
+ self.model.AddBoolOr([is_consecutive_1_2, is_consecutive_2_1]).OnlyEnforceIf(are_consecutive)
330
+ self.model.AddBoolAnd([is_consecutive_1_2.Not(), is_consecutive_2_1.Not()]).OnlyEnforceIf(are_consecutive.Not())
331
+
332
+ same_room = self.model.NewBoolVar(f"same_room_{t1.task_id}_{t2.task_id}")
333
+ self.model.Add(room1 == room2).OnlyEnforceIf(same_room)
334
+ self.model.Add(room1 != room2).OnlyEnforceIf(same_room.Not())
335
+
336
+ move_penalty = self.model.NewBoolVar(f"move_{t1.task_id}_{t2.task_id}")
337
+ self.model.AddBoolAnd([are_consecutive, same_room.Not()]).OnlyEnforceIf(move_penalty)
338
+
339
+ self.penalties.append(move_penalty * weight)
340
 
341
  def _penalize_first_hour_free(self):
 
 
 
342
  pass
343
 
344
  def _penalize_empty_morning_slots(self):
 
 
 
 
345
  weight = self.weights.get("pack_morning", 50)
346
  if weight == 0: return
347
 
 
352
  for sec_id, s_tasks in tasks_by_section.items():
353
  if not s_tasks: continue
354
 
355
+ morning_overlaps = []
356
+ for task in s_tasks:
357
+ d_start = self.t_dstart[task.task_id]
358
+ d_end = self.t_dend[task.task_id]
359
+
360
+ capped_start = self.model.NewIntVar(0, 4, "")
361
+ self.model.AddMinEquality(capped_start, [d_start, 4])
362
+
363
+ capped_end = self.model.NewIntVar(0, 4, "")
364
+ self.model.AddMinEquality(capped_end, [d_end, 4])
365
+
366
+ overlap = self.model.NewIntVar(0, 4, "")
367
+ self.model.Add(overlap == capped_end - capped_start)
368
+ morning_overlaps.append(overlap)
369
+
370
+ # Total morning slots are 20 (5 days * 4 slots)
371
+ total_slots = const.NUM_WORKING_DAYS * 4
372
+ empty_slots = self.model.NewIntVar(0, total_slots, f"sec_{sec_id}_empty_morning")
373
+ self.model.Add(empty_slots == total_slots - sum(morning_overlaps))
374
+
375
+ self.penalties.append(empty_slots * weight)
 
 
 
 
 
 
 
 
 
 
376
 
377
  def _penalize_late_afternoon_slots(self):
 
 
 
 
378
  weight = self.weights.get("avoid_late_afternoon", 40)
379
  if weight == 0: return
380
 
 
381
  for task in self.tasks:
382
+ d_start = self.t_dstart[task.task_id]
383
+ d_end = self.t_dend[task.task_id]
384
+
385
+ capped_start = self.model.NewIntVar(6, const.NUM_TEACHING_SLOTS_PER_DAY, "")
386
+ self.model.AddMaxEquality(capped_start, [d_start, 6])
387
+
388
+ capped_end = self.model.NewIntVar(6, const.NUM_TEACHING_SLOTS_PER_DAY, "")
389
+ self.model.AddMaxEquality(capped_end, [d_end, 6])
390
+
391
+ overlap = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 6, "")
392
+ self.model.Add(overlap == capped_end - capped_start)
393
+
394
+ self.penalties.append(overlap * weight)