KindAlien commited on
Commit
6dfe51d
·
verified ·
1 Parent(s): 7c82afb

Update objective_engine.py

Browse files
Files changed (1) hide show
  1. objective_engine.py +145 -302
objective_engine.py CHANGED
@@ -50,72 +50,37 @@ class ObjectiveEngine:
50
  self.weights = weights or {
51
  "subject_repetition": 10,
52
  "morning_core": 5,
53
- "late_heavy": 10,
54
  "faculty_gaps": 2,
55
- "campus_movement": 5,
56
- "no_first_hour_free": 20,
57
- "student_gaps": 500
58
  }
59
 
60
  self.penalties: List[cp_model.IntVar] = []
61
 
62
- # Pre-computed day booleans (shared across soft constraints)
63
- # task_day_bools[task_id][day] = BoolVar "is task on this day?"
64
- self._task_day_bools: Dict[str, Dict[int, cp_model.IntVar]] = {}
65
- self._task_day_vars: Dict[str, cp_model.IntVar] = {}
66
- self._daily_slot_vars: Dict[str, cp_model.IntVar] = {}
67
-
68
- def _ensure_day_bools(self, task: 'Task'):
69
- """Lazily create and cache per-task day booleans & daily slot vars."""
70
- tid = task.task_id
71
- if tid in self._task_day_bools:
72
- return
73
-
74
- start_var = self.ce.task_vars[tid][0]
75
-
76
- # Day index (0..NUM_WORKING_DAYS-1) for this task
77
- day_var = self.model.NewIntVar(
78
- 0, const.NUM_WORKING_DAYS - 1, f"dayvar_{tid}")
79
- self.model.AddDivisionEquality(
80
- day_var, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
81
- self._task_day_vars[tid] = day_var
82
-
83
- # Daily slot index (0..NUM_TEACHING_SLOTS_PER_DAY-1)
84
- daily_slot = self.model.NewIntVar(
85
- 0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"dslot_{tid}")
86
- self.model.AddModuloEquality(
87
- daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
88
- self._daily_slot_vars[tid] = daily_slot
89
-
90
- # Per-day boolean
91
- day_bools = {}
92
- for day in range(const.NUM_WORKING_DAYS):
93
- b = self.model.NewBoolVar(f"{tid}_onday{day}")
94
- self.model.Add(day_var == day).OnlyEnforceIf(b)
95
- self.model.Add(day_var != day).OnlyEnforceIf(b.Not())
96
- day_bools[day] = b
97
- self._task_day_bools[tid] = day_bools
98
-
99
  def build_objective(self):
100
  """
101
  Applies all configured soft constraints and sets the minimization objective.
102
- All constraints are designed to scale linearly with task count.
103
  """
104
- print(f"Building Objective Function ({len(self.tasks)} tasks)...")
105
 
106
  self._minimize_subject_repetition()
107
  self._prioritize_morning_core_subjects()
108
  self._avoid_late_heavy_subjects()
109
- self._minimize_faculty_gaps()
110
- self._minimize_campus_movement()
111
- self._pack_morning_session()
112
- self._minimize_student_gaps()
 
 
 
 
113
 
114
  # Summation of all penalties
115
  if self.penalties:
116
  total_cost = sum(self.penalties)
117
  self.model.Minimize(total_cost)
118
- print(f" → {len(self.penalties)} penalty terms added.")
119
  else:
120
  self.model.Minimize(0)
121
 
@@ -177,8 +142,10 @@ class ObjectiveEngine:
177
 
178
  for task in self.tasks:
179
  if task.subject.is_core and task.subject.subject_type == SubjectType.THEORY:
180
- self._ensure_day_bools(task)
181
- daily_slot = self._daily_slot_vars[task.task_id]
 
 
182
 
183
  # Penalty if daily_slot >= afternoon_start_index
184
  is_afternoon = self.model.NewBoolVar(f"is_afternoon_{task.task_id}")
@@ -198,8 +165,10 @@ class ObjectiveEngine:
198
 
199
  for task in self.tasks:
200
  if task.subject.is_heavy:
201
- self._ensure_day_bools(task)
202
- daily_slot = self._daily_slot_vars[task.task_id]
 
 
203
 
204
  is_last_slot = self.model.NewBoolVar(f"is_last_slot_{task.task_id}")
205
  self.model.Add(daily_slot == last_slot_index).OnlyEnforceIf(is_last_slot)
@@ -209,146 +178,89 @@ class ObjectiveEngine:
209
 
210
  def _minimize_faculty_gaps(self):
211
  """
212
- Penalizes 'idle spans' for faculty using an O(N) approach.
213
-
214
- Instead of tracking min-start / max-end per faculty per day
215
- (which requires O(tasks_per_faculty × days) auxiliary variables
216
- with conditional min/max), we use a simpler counting approach:
217
-
218
- For each faculty on each day, count the number of tasks scheduled.
219
- If count >= 2, the span necessarily introduces potential gaps.
220
- The penalty is proportional to (count - 1) since that's the
221
- maximum number of gaps possible. The actual gap size is left
222
- to the solver's domain reduction.
223
  """
224
  weight = self.weights.get("faculty_gaps", 0)
225
  if weight == 0: return
226
 
227
  # Group tasks by faculty
228
- tasks_by_faculty = defaultdict(list)
229
  faculty_ids_set = {f.id for f in self.faculties}
230
  for task in self.tasks:
231
  parts = task.faculty.id.split('_')
232
  fids = parts if len(parts) > 1 and all(p in faculty_ids_set for p in parts) else [task.faculty.id]
233
  for fid in fids:
234
- if fid != "DUMMY_STAFF":
235
  tasks_by_faculty[fid].append(task)
236
 
237
  for faculty_id, f_tasks in tasks_by_faculty.items():
238
- if len(f_tasks) < 2:
239
  continue
240
 
241
- # Ensure day booleans exist for all tasks of this faculty
242
- for task in f_tasks:
243
- self._ensure_day_bools(task)
244
-
245
  for day in range(const.NUM_WORKING_DAYS):
246
- # Collect "is on day" booleans for this faculty's tasks
247
- on_day_bools = []
248
- for task in f_tasks:
249
- on_day_bools.append(
250
- self._task_day_bools[task.task_id][day])
251
-
252
- # Count tasks on this day
253
- count_on_day = self.model.NewIntVar(
254
- 0, len(f_tasks),
255
- f"fgap_cnt_{faculty_id}_d{day}")
256
- self.model.Add(count_on_day == sum(on_day_bools))
257
-
258
- # Penalize if there's more than 1 task (potential gaps)
259
- # Penalty = max(0, count - 1) * weight
260
- # Since count >= 0, we can use: penalty_val = count - 1
261
- # clamped to 0 via a helper var
262
- gap_potential = self.model.NewIntVar(
263
- 0, len(f_tasks),
264
- f"fgap_pot_{faculty_id}_d{day}")
265
- self.model.AddMaxEquality(
266
- gap_potential, [count_on_day - 1, 0])
267
-
268
- self.penalties.append(gap_potential * weight)
269
-
270
- def _minimize_student_gaps(self):
271
- """
272
- Penalizes gaps in a section's schedule on a single day.
273
- A slot S is a gap if there is a class before S on the same day,
274
- a class after S on the same day, and S itself is free.
275
- Uses an O(N) boolean representation.
276
- """
277
- weight = self.weights.get("student_gaps", 0)
278
- if weight == 0: return
279
 
280
- from collections import defaultdict
281
- tasks_by_sec = defaultdict(list)
282
- for task in self.tasks:
283
- tasks_by_sec[task.section.section_id].append(task)
 
 
 
 
 
 
 
284
 
285
- for sec_id, sec_tasks in tasks_by_sec.items():
286
- if not sec_tasks: continue
287
 
288
- for day in range(const.NUM_WORKING_DAYS):
289
- # 1. Determine if the section has any class at each slot
290
- active_at_slot = []
291
- for slot in range(const.NUM_TEACHING_SLOTS_PER_DAY):
292
- abs_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY + slot
293
- slot_covs = []
294
-
295
- for task in sec_tasks:
296
- start_var = self.ce.task_vars[task.task_id][0]
297
- # task covers abs_slot if start <= abs_slot AND end > abs_slot
298
- cov = self.model.NewBoolVar(f"stu_cov_{task.task_id}_s{abs_slot}")
299
- b1 = self.model.NewBoolVar(f"sb1_{task.task_id}_s{abs_slot}")
300
- b2 = self.model.NewBoolVar(f"sb2_{task.task_id}_s{abs_slot}")
301
-
302
- self.model.Add(start_var <= abs_slot).OnlyEnforceIf(b1)
303
- self.model.Add(start_var > abs_slot).OnlyEnforceIf(b1.Not())
304
- self.model.Add(start_var + task.duration > abs_slot).OnlyEnforceIf(b2)
305
- self.model.Add(start_var + task.duration <= abs_slot).OnlyEnforceIf(b2.Not())
306
-
307
- self.model.AddBoolAnd([b1, b2]).OnlyEnforceIf(cov)
308
- self.model.AddBoolOr([b1.Not(), b2.Not()]).OnlyEnforceIf(cov.Not())
309
- slot_covs.append(cov)
310
-
311
- is_active = self.model.NewBoolVar(f"sec_act_{sec_id}_d{day}_s{slot}")
312
- self.model.AddBoolOr(slot_covs).OnlyEnforceIf(is_active)
313
- for c in slot_covs:
314
- self.model.AddImplication(is_active.Not(), c.Not())
315
- active_at_slot.append(is_active)
316
-
317
- # 2. Identify gaps (free slot surrounded by active slots)
318
- for slot in range(1, const.NUM_TEACHING_SLOTS_PER_DAY - 1):
319
- any_before = self.model.NewBoolVar(f"bef_{sec_id}_d{day}_s{slot}")
320
- self.model.AddBoolOr(active_at_slot[:slot]).OnlyEnforceIf(any_before)
321
- for c in active_at_slot[:slot]:
322
- self.model.AddImplication(any_before.Not(), c.Not())
323
-
324
- any_after = self.model.NewBoolVar(f"aft_{sec_id}_d{day}_s{slot}")
325
- self.model.AddBoolOr(active_at_slot[slot+1:]).OnlyEnforceIf(any_after)
326
- for c in active_at_slot[slot+1:]:
327
- self.model.AddImplication(any_after.Not(), c.Not())
328
-
329
- is_gap = self.model.NewBoolVar(f"gap_{sec_id}_d{day}_s{slot}")
330
- self.model.AddBoolAnd([active_at_slot[slot].Not(), any_before, any_after]).OnlyEnforceIf(is_gap)
331
- self.model.AddBoolOr([active_at_slot[slot], any_before.Not(), any_after.Not()]).OnlyEnforceIf(is_gap.Not())
332
 
333
- self.penalties.append(is_gap * weight)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
  def _minimize_campus_movement(self):
336
  """
337
  Penalizes consecutive tasks for a section that are in different buildings.
338
-
339
- Rewritten to O(N) by only checking adjacent-period pairs on the same day
340
- instead of all O(N²) task pairs.
341
-
342
- For each section and each day, for each pair of adjacent teaching slots,
343
- check if the tasks in those slots are in different buildings.
344
  """
345
  weight = self.weights.get("campus_movement", 0)
346
  if weight == 0: return
347
 
348
  unique_buildings = sorted(list(set(r.building for r in self.rooms)))
349
- if len(unique_buildings) <= 1:
350
- return # Only one building — no campus movement possible
351
-
352
  building_to_id = {b: i for i, b in enumerate(unique_buildings)}
353
  room_idx_to_building_id = [building_to_id[r.building] for r in self.rooms]
354
 
@@ -357,150 +269,81 @@ class ObjectiveEngine:
357
  tasks_by_section[task.section.section_id].append(task)
358
 
359
  for sec_id, sec_tasks in tasks_by_section.items():
360
- if len(sec_tasks) < 2:
361
- continue
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
- # Ensure day booleans exist
364
- for task in sec_tasks:
365
- self._ensure_day_bools(task)
 
 
366
 
367
- # For each day, for each pair of adjacent slots, check if two
368
- # different tasks from this section occupy them and are in
369
- # different buildings.
370
- for day in range(const.NUM_WORKING_DAYS):
371
- day_offset = day * const.NUM_TEACHING_SLOTS_PER_DAY
372
-
373
- for slot in range(const.NUM_TEACHING_SLOTS_PER_DAY - 1):
374
- abs_slot = day_offset + slot
375
- next_abs_slot = day_offset + slot + 1
376
-
377
- # Find tasks that could be at abs_slot and next_abs_slot
378
- # A task covers abs_slot if start <= abs_slot < start + dur
379
- # For efficiency, we check start_var constraints
380
-
381
- tasks_at_slot = []
382
- tasks_at_next = []
383
-
384
- for task in sec_tasks:
385
- start_var = self.ce.task_vars[task.task_id][0]
386
- # Task covers abs_slot if start <= abs_slot < start + dur
387
- # We need proper reification: cov <=> (start <= abs_slot AND end > abs_slot)
388
-
389
- # For abs_slot
390
- cov = self.model.NewBoolVar(f"cov_{task.task_id}_s{abs_slot}")
391
- b1 = self.model.NewBoolVar(f"b1_{task.task_id}_s{abs_slot}")
392
- b2 = self.model.NewBoolVar(f"b2_{task.task_id}_s{abs_slot}")
393
-
394
- self.model.Add(start_var <= abs_slot).OnlyEnforceIf(b1)
395
- self.model.Add(start_var > abs_slot).OnlyEnforceIf(b1.Not())
396
- self.model.Add(start_var + task.duration > abs_slot).OnlyEnforceIf(b2)
397
- self.model.Add(start_var + task.duration <= abs_slot).OnlyEnforceIf(b2.Not())
398
-
399
- self.model.AddBoolAnd([b1, b2]).OnlyEnforceIf(cov)
400
- self.model.AddBoolOr([b1.Not(), b2.Not()]).OnlyEnforceIf(cov.Not())
401
- tasks_at_slot.append((task, cov))
402
-
403
- # For next_abs_slot
404
- cov_next = self.model.NewBoolVar(f"cov_{task.task_id}_s{next_abs_slot}")
405
- bn1 = self.model.NewBoolVar(f"bn1_{task.task_id}_s{next_abs_slot}")
406
- bn2 = self.model.NewBoolVar(f"bn2_{task.task_id}_s{next_abs_slot}")
407
-
408
- self.model.Add(start_var <= next_abs_slot).OnlyEnforceIf(bn1)
409
- self.model.Add(start_var > next_abs_slot).OnlyEnforceIf(bn1.Not())
410
- self.model.Add(start_var + task.duration > next_abs_slot).OnlyEnforceIf(bn2)
411
- self.model.Add(start_var + task.duration <= next_abs_slot).OnlyEnforceIf(bn2.Not())
412
-
413
- self.model.AddBoolAnd([bn1, bn2]).OnlyEnforceIf(cov_next)
414
- self.model.AddBoolOr([bn1.Not(), bn2.Not()]).OnlyEnforceIf(cov_next.Not())
415
- tasks_at_next.append((task, cov_next))
416
-
417
- # For each pair (one at slot, one at next_slot, different tasks),
418
- # check if they're in different buildings.
419
- # To keep this tractable, we only penalize if ANY task at slot
420
- # and ANY task at next_slot are in different buildings.
421
- # We approximate: penalize if slot is occupied AND next_slot is
422
- # occupied (movement required regardless of building).
423
- # This is a much cheaper O(1)-per-slot approximation.
424
- if tasks_at_slot and tasks_at_next:
425
- any_at_slot = self.model.NewBoolVar(
426
- f"any_{sec_id}_d{day}_s{slot}")
427
- any_at_next = self.model.NewBoolVar(
428
- f"any_{sec_id}_d{day}_s{slot + 1}")
429
-
430
- self.model.AddBoolOr(
431
- [c for _, c in tasks_at_slot]
432
- ).OnlyEnforceIf(any_at_slot)
433
- for _, c in tasks_at_slot:
434
- self.model.AddImplication(
435
- any_at_slot.Not(), c.Not())
436
-
437
- self.model.AddBoolOr(
438
- [c for _, c in tasks_at_next]
439
- ).OnlyEnforceIf(any_at_next)
440
- for _, c in tasks_at_next:
441
- self.model.AddImplication(
442
- any_at_next.Not(), c.Not())
443
-
444
- # Movement penalty: both slots occupied = potential
445
- # building change (simplified — exact building check
446
- # was O(n²) and is too expensive for large models)
447
- both_occupied = self.model.NewBoolVar(
448
- f"bothocc_{sec_id}_d{day}_s{slot}")
449
- self.model.AddBoolAnd(
450
- [any_at_slot, any_at_next]
451
- ).OnlyEnforceIf(both_occupied)
452
- self.model.AddBoolOr(
453
- [any_at_slot.Not(), any_at_next.Not()]
454
- ).OnlyEnforceIf(both_occupied.Not())
455
-
456
- # Use a reduced weight since this is an approximation
457
- self.penalties.append(both_occupied * max(1, weight // 2))
458
-
459
- def _pack_morning_session(self):
460
  """
461
- Penalizes any free slot in the first 4 hours (morning session) for any section.
462
- This strongly forces the solver to pack the morning tightly, leaving afternoons free if necessary,
463
- and completely eliminating gaps in the first 4 hours.
 
 
 
464
  """
465
- weight = self.weights.get("pack_morning", 500)
466
- if weight == 0: return
 
467
 
468
- from collections import defaultdict
469
- tasks_by_sec = defaultdict(list)
470
  for task in self.tasks:
471
- tasks_by_sec[task.section.section_id].append(task)
472
-
473
- for sec_id, sec_tasks in tasks_by_sec.items():
474
- if not sec_tasks: continue
475
 
 
476
  for day in range(const.NUM_WORKING_DAYS):
477
- for slot in range(4): # First 4 hours
478
- abs_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY + slot
479
- slot_covs = []
480
-
481
- for task in sec_tasks:
482
- start_var = self.ce.task_vars[task.task_id][0]
483
- cov = self.model.NewBoolVar(f"morn_cov_{task.task_id}_s{abs_slot}")
484
- b1 = self.model.NewBoolVar(f"mb1_{task.task_id}_s{abs_slot}")
485
- b2 = self.model.NewBoolVar(f"mb2_{task.task_id}_s{abs_slot}")
486
-
487
- self.model.Add(start_var <= abs_slot).OnlyEnforceIf(b1)
488
- self.model.Add(start_var > abs_slot).OnlyEnforceIf(b1.Not())
489
- self.model.Add(start_var + task.duration > abs_slot).OnlyEnforceIf(b2)
490
- self.model.Add(start_var + task.duration <= abs_slot).OnlyEnforceIf(b2.Not())
491
-
492
- self.model.AddBoolAnd([b1, b2]).OnlyEnforceIf(cov)
493
- self.model.AddBoolOr([b1.Not(), b2.Not()]).OnlyEnforceIf(cov.Not())
494
- slot_covs.append(cov)
495
-
496
- is_active = self.model.NewBoolVar(f"morn_act_{sec_id}_d{day}_s{slot}")
497
- self.model.AddBoolOr(slot_covs).OnlyEnforceIf(is_active)
498
- for c in slot_covs:
499
- self.model.AddImplication(is_active.Not(), c.Not())
500
-
501
- # We penalize if is_active is False
502
- is_free = self.model.NewBoolVar(f"morn_free_{sec_id}_d{day}_s{slot}")
503
- self.model.Add(is_active == 0).OnlyEnforceIf(is_free)
504
- self.model.Add(is_active == 1).OnlyEnforceIf(is_free.Not())
505
-
506
- self.penalties.append(is_free * weight)
 
50
  self.weights = weights or {
51
  "subject_repetition": 10,
52
  "morning_core": 5,
53
+ "late_heavy": 5,
54
  "faculty_gaps": 2,
55
+ "campus_movement": 3,
56
+ "faculty_load_balance": 1,
57
+ "no_first_hour_free": 20
58
  }
59
 
60
  self.penalties: List[cp_model.IntVar] = []
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def build_objective(self):
63
  """
64
  Applies all configured soft constraints and sets the minimization objective.
 
65
  """
66
+ print("Building Objective Function...")
67
 
68
  self._minimize_subject_repetition()
69
  self._prioritize_morning_core_subjects()
70
  self._avoid_late_heavy_subjects()
71
+
72
+ # Disable highly expensive constraints for massive math (large datasets)
73
+ if len(self.tasks) <= 100:
74
+ self._minimize_faculty_gaps()
75
+ self._minimize_campus_movement()
76
+ self._penalize_first_hour_free()
77
+ else:
78
+ print(f"Skipping expensive soft constraints due to massive math (Tasks: {len(self.tasks)})")
79
 
80
  # Summation of all penalties
81
  if self.penalties:
82
  total_cost = sum(self.penalties)
83
  self.model.Minimize(total_cost)
 
84
  else:
85
  self.model.Minimize(0)
86
 
 
142
 
143
  for task in self.tasks:
144
  if task.subject.is_core and task.subject.subject_type == SubjectType.THEORY:
145
+ start_var = self.ce.task_vars[task.task_id][0]
146
+
147
+ daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_{task.task_id}")
148
+ self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
149
 
150
  # Penalty if daily_slot >= afternoon_start_index
151
  is_afternoon = self.model.NewBoolVar(f"is_afternoon_{task.task_id}")
 
165
 
166
  for task in self.tasks:
167
  if task.subject.is_heavy:
168
+ start_var = self.ce.task_vars[task.task_id][0]
169
+
170
+ daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_heavy_{task.task_id}")
171
+ self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
172
 
173
  is_last_slot = self.model.NewBoolVar(f"is_last_slot_{task.task_id}")
174
  self.model.Add(daily_slot == last_slot_index).OnlyEnforceIf(is_last_slot)
 
178
 
179
  def _minimize_faculty_gaps(self):
180
  """
181
+ Penalizes 'idle spans' for faculty.
182
+ We approximate this by minimizing (Daily End Time - Daily Start Time - Total Teaching Duration).
 
 
 
 
 
 
 
 
 
183
  """
184
  weight = self.weights.get("faculty_gaps", 0)
185
  if weight == 0: return
186
 
187
  # Group tasks by faculty
188
+ tasks_by_faculty = {f.id: [] for f in self.faculties}
189
  faculty_ids_set = {f.id for f in self.faculties}
190
  for task in self.tasks:
191
  parts = task.faculty.id.split('_')
192
  fids = parts if len(parts) > 1 and all(p in faculty_ids_set for p in parts) else [task.faculty.id]
193
  for fid in fids:
194
+ if fid in tasks_by_faculty:
195
  tasks_by_faculty[fid].append(task)
196
 
197
  for faculty_id, f_tasks in tasks_by_faculty.items():
198
+ if not f_tasks:
199
  continue
200
 
 
 
 
 
201
  for day in range(const.NUM_WORKING_DAYS):
202
+ day_offset_start = day * const.NUM_TEACHING_SLOTS_PER_DAY
203
+ day_offset_end = (day + 1) * const.NUM_TEACHING_SLOTS_PER_DAY
204
+
205
+ # Variables to track if faculty is active on this day, and their start/end
206
+ day_active = self.model.NewBoolVar(f"active_{faculty_id}_{day}")
207
+ day_start = self.model.NewIntVar(day_offset_start, day_offset_end, f"start_{faculty_id}_{day}")
208
+ day_end = self.model.NewIntVar(day_offset_start, day_offset_end, f"end_{faculty_id}_{day}")
209
+
210
+ task_on_day_lits = []
211
+ total_duration_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"dur_{faculty_id}_{day}")
212
+
213
+ durations_sum = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
+ for task in f_tasks:
216
+ t_start = self.ce.task_vars[task.task_id][0]
217
+ t_end = self.ce.task_vars[task.task_id][1]
218
+
219
+ is_on_day = self.model.NewBoolVar(f"{task.task_id}_on_day_{day}")
220
+
221
+ t_day = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"t_day_{task.task_id}_{day}")
222
+ self.model.AddDivisionEquality(t_day, t_start, const.NUM_TEACHING_SLOTS_PER_DAY)
223
+
224
+ self.model.Add(t_day == day).OnlyEnforceIf(is_on_day)
225
+ self.model.Add(t_day != day).OnlyEnforceIf(is_on_day.Not())
226
 
227
+ task_on_day_lits.append(is_on_day)
 
228
 
229
+ # Update min start and max end for the day ONLY if task is on this day
230
+ self.model.Add(day_start <= t_start).OnlyEnforceIf(is_on_day)
231
+ self.model.Add(day_end >= t_end).OnlyEnforceIf(is_on_day)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
+ # Accumulate duration
234
+ dur_term = self.model.NewIntVar(0, task.duration, f"dur_term_{task.task_id}_{day}")
235
+ self.model.Add(dur_term == task.duration).OnlyEnforceIf(is_on_day)
236
+ self.model.Add(dur_term == 0).OnlyEnforceIf(is_on_day.Not())
237
+ durations_sum.append(dur_term)
238
+
239
+ # If no tasks on this day, force active to false
240
+ self.model.Add(sum(task_on_day_lits) > 0).OnlyEnforceIf(day_active)
241
+ self.model.Add(sum(task_on_day_lits) == 0).OnlyEnforceIf(day_active.Not())
242
+
243
+ # --- FIX: Use Python sum() inside Add() instead of self.model.Sum() ---
244
+ self.model.Add(total_duration_on_day == sum(durations_sum))
245
+
246
+ span = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"span_{faculty_id}_{day}")
247
+ self.model.Add(span == day_end - day_start).OnlyEnforceIf(day_active)
248
+ self.model.Add(span == 0).OnlyEnforceIf(day_active.Not())
249
+
250
+ idle_time = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"idle_{faculty_id}_{day}")
251
+ self.model.Add(idle_time == span - total_duration_on_day).OnlyEnforceIf(day_active)
252
+ self.model.Add(idle_time == 0).OnlyEnforceIf(day_active.Not())
253
+
254
+ self.penalties.append(idle_time * weight)
255
 
256
  def _minimize_campus_movement(self):
257
  """
258
  Penalizes consecutive tasks for a section that are in different buildings.
 
 
 
 
 
 
259
  """
260
  weight = self.weights.get("campus_movement", 0)
261
  if weight == 0: return
262
 
263
  unique_buildings = sorted(list(set(r.building for r in self.rooms)))
 
 
 
264
  building_to_id = {b: i for i, b in enumerate(unique_buildings)}
265
  room_idx_to_building_id = [building_to_id[r.building] for r in self.rooms]
266
 
 
269
  tasks_by_section[task.section.section_id].append(task)
270
 
271
  for sec_id, sec_tasks in tasks_by_section.items():
272
+ if len(sec_tasks) < 2: continue
273
+
274
+ for i in range(len(sec_tasks)):
275
+ for j in range(len(sec_tasks)):
276
+ if i == j: continue
277
+ t1 = sec_tasks[i]
278
+ t2 = sec_tasks[j]
279
+
280
+ t1_end = self.ce.task_vars[t1.task_id][1]
281
+ t2_start = self.ce.task_vars[t2.task_id][0]
282
+
283
+ is_consecutive = self.model.NewBoolVar(f"consec_{t1.task_id}_{t2.task_id}")
284
+ self.model.Add(t1_end == t2_start).OnlyEnforceIf(is_consecutive)
285
+ self.model.Add(t1_end != t2_start).OnlyEnforceIf(is_consecutive.Not())
286
 
287
+ b1_var = self.model.NewIntVar(0, len(unique_buildings), f"bld_{t1.task_id}")
288
+ b2_var = self.model.NewIntVar(0, len(unique_buildings), f"bld_{t2.task_id}")
289
+
290
+ room_var_1 = self.ce.task_vars[t1.task_id][3]
291
+ room_var_2 = self.ce.task_vars[t2.task_id][3]
292
 
293
+ self.model.AddElement(room_var_1, room_idx_to_building_id, b1_var)
294
+ self.model.AddElement(room_var_2, room_idx_to_building_id, b2_var)
295
+
296
+ diff_building = self.model.NewBoolVar(f"diff_bld_{t1.task_id}_{t2.task_id}")
297
+ self.model.Add(b1_var != b2_var).OnlyEnforceIf(diff_building)
298
+ self.model.Add(b1_var == b2_var).OnlyEnforceIf(diff_building.Not())
299
+
300
+ penalty_active = self.model.NewBoolVar(f"move_pen_{t1.task_id}_{t2.task_id}")
301
+ self.model.AddBoolAnd([is_consecutive, diff_building]).OnlyEnforceIf(penalty_active)
302
+
303
+ self.penalties.append(penalty_active * weight)
304
+
305
+ def _penalize_first_hour_free(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  """
307
+ Strongly penalizes having the first hour (period index 0) free for any
308
+ section on any day. The solver will avoid this unless there is genuinely
309
+ no other feasible assignment.
310
+
311
+ Since tasks never cross day boundaries, a task covers period 0 of a day
312
+ if and only if its start_var equals that day's first absolute slot index.
313
  """
314
+ weight = self.weights.get("no_first_hour_free", 20)
315
+ if weight == 0:
316
+ return
317
 
318
+ # Group tasks by section
319
+ tasks_by_section = defaultdict(list)
320
  for task in self.tasks:
321
+ tasks_by_section[task.section.section_id].append(task)
 
 
 
322
 
323
+ for sec_id, sec_tasks in tasks_by_section.items():
324
  for day in range(const.NUM_WORKING_DAYS):
325
+ # The absolute slot index for period 0 of this day
326
+ first_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY
327
+
328
+ # For each task, create a bool: does it start at exactly first_slot?
329
+ starts_at_first = []
330
+ for task in sec_tasks:
331
+ start_var = self.ce.task_vars[task.task_id][0]
332
+
333
+ at_first = self.model.NewBoolVar(f"at1st_{task.task_id}_d{day}")
334
+ self.model.Add(start_var == first_slot).OnlyEnforceIf(at_first)
335
+ self.model.Add(start_var != first_slot).OnlyEnforceIf(at_first.Not())
336
+ starts_at_first.append(at_first)
337
+
338
+ # any_at_first = True if at least one task starts at period 0
339
+ any_at_first = self.model.NewBoolVar(f"any_at1st_{sec_id}_d{day}")
340
+ self.model.AddBoolOr(starts_at_first).OnlyEnforceIf(any_at_first)
341
+ for lit in starts_at_first:
342
+ self.model.AddImplication(any_at_first.Not(), lit.Not())
343
+
344
+ # Penalty when the first hour IS free (no task at period 0)
345
+ first_free = self.model.NewBoolVar(f"first_free_{sec_id}_d{day}")
346
+ self.model.Add(first_free == 1).OnlyEnforceIf(any_at_first.Not())
347
+ self.model.Add(first_free == 0).OnlyEnforceIf(any_at_first)
348
+
349
+ self.penalties.append(first_free * weight)