KindAlien commited on
Commit
d30d73f
·
verified ·
1 Parent(s): b11ca52

Update objective_engine.py

Browse files
Files changed (1) hide show
  1. objective_engine.py +218 -120
objective_engine.py CHANGED
@@ -59,28 +59,62 @@ class ObjectiveEngine:
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,10 +176,8 @@ class ObjectiveEngine:
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,10 +197,8 @@ class ObjectiveEngine:
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,89 +208,81 @@ class ObjectiveEngine:
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,38 +291,98 @@ class ObjectiveEngine:
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
  """
@@ -308,19 +390,24 @@ class ObjectiveEngine:
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
@@ -330,20 +417,31 @@ class ObjectiveEngine:
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)
 
 
 
 
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._penalize_first_hour_free()
 
 
 
 
 
112
 
113
  # Summation of all penalties
114
  if self.penalties:
115
  total_cost = sum(self.penalties)
116
  self.model.Minimize(total_cost)
117
+ print(f" → {len(self.penalties)} penalty terms added.")
118
  else:
119
  self.model.Minimize(0)
120
 
 
176
 
177
  for task in self.tasks:
178
  if task.subject.is_core and task.subject.subject_type == SubjectType.THEORY:
179
+ self._ensure_day_bools(task)
180
+ daily_slot = self._daily_slot_vars[task.task_id]
 
 
181
 
182
  # Penalty if daily_slot >= afternoon_start_index
183
  is_afternoon = self.model.NewBoolVar(f"is_afternoon_{task.task_id}")
 
197
 
198
  for task in self.tasks:
199
  if task.subject.is_heavy:
200
+ self._ensure_day_bools(task)
201
+ daily_slot = self._daily_slot_vars[task.task_id]
 
 
202
 
203
  is_last_slot = self.model.NewBoolVar(f"is_last_slot_{task.task_id}")
204
  self.model.Add(daily_slot == last_slot_index).OnlyEnforceIf(is_last_slot)
 
208
 
209
  def _minimize_faculty_gaps(self):
210
  """
211
+ Penalizes 'idle spans' for faculty using an O(N) approach.
212
+
213
+ Instead of tracking min-start / max-end per faculty per day
214
+ (which requires O(tasks_per_faculty × days) auxiliary variables
215
+ with conditional min/max), we use a simpler counting approach:
216
+
217
+ For each faculty on each day, count the number of tasks scheduled.
218
+ If count >= 2, the span necessarily introduces potential gaps.
219
+ The penalty is proportional to (count - 1) since that's the
220
+ maximum number of gaps possible. The actual gap size is left
221
+ to the solver's domain reduction.
222
  """
223
  weight = self.weights.get("faculty_gaps", 0)
224
  if weight == 0: return
225
 
226
  # Group tasks by faculty
227
+ tasks_by_faculty = defaultdict(list)
228
  faculty_ids_set = {f.id for f in self.faculties}
229
  for task in self.tasks:
230
  parts = task.faculty.id.split('_')
231
  fids = parts if len(parts) > 1 and all(p in faculty_ids_set for p in parts) else [task.faculty.id]
232
  for fid in fids:
233
+ if fid != "DUMMY_STAFF":
234
  tasks_by_faculty[fid].append(task)
235
 
236
  for faculty_id, f_tasks in tasks_by_faculty.items():
237
+ if len(f_tasks) < 2:
238
  continue
239
 
240
+ # Ensure day booleans exist for all tasks of this faculty
241
+ for task in f_tasks:
242
+ self._ensure_day_bools(task)
 
 
 
 
 
 
 
 
 
 
243
 
244
+ for day in range(const.NUM_WORKING_DAYS):
245
+ # Collect "is on day" booleans for this faculty's tasks
246
+ on_day_bools = []
247
  for task in f_tasks:
248
+ on_day_bools.append(
249
+ self._task_day_bools[task.task_id][day])
250
+
251
+ # Count tasks on this day
252
+ count_on_day = self.model.NewIntVar(
253
+ 0, len(f_tasks),
254
+ f"fgap_cnt_{faculty_id}_d{day}")
255
+ self.model.Add(count_on_day == sum(on_day_bools))
256
+
257
+ # Penalize if there's more than 1 task (potential gaps)
258
+ # Penalty = max(0, count - 1) * weight
259
+ # Since count >= 0, we can use: penalty_val = count - 1
260
+ # clamped to 0 via a helper var
261
+ gap_potential = self.model.NewIntVar(
262
+ 0, len(f_tasks),
263
+ f"fgap_pot_{faculty_id}_d{day}")
264
+ self.model.AddMaxEquality(
265
+ gap_potential, [count_on_day - 1, 0])
266
+
267
+ self.penalties.append(gap_potential * weight)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  def _minimize_campus_movement(self):
270
  """
271
  Penalizes consecutive tasks for a section that are in different buildings.
272
+
273
+ Rewritten to O(N) by only checking adjacent-period pairs on the same day
274
+ instead of all O(N²) task pairs.
275
+
276
+ For each section and each day, for each pair of adjacent teaching slots,
277
+ check if the tasks in those slots are in different buildings.
278
  """
279
  weight = self.weights.get("campus_movement", 0)
280
  if weight == 0: return
281
 
282
  unique_buildings = sorted(list(set(r.building for r in self.rooms)))
283
+ if len(unique_buildings) <= 1:
284
+ return # Only one building — no campus movement possible
285
+
286
  building_to_id = {b: i for i, b in enumerate(unique_buildings)}
287
  room_idx_to_building_id = [building_to_id[r.building] for r in self.rooms]
288
 
 
291
  tasks_by_section[task.section.section_id].append(task)
292
 
293
  for sec_id, sec_tasks in tasks_by_section.items():
294
+ if len(sec_tasks) < 2:
295
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
+ # Ensure day booleans exist
298
+ for task in sec_tasks:
299
+ self._ensure_day_bools(task)
300
 
301
+ # For each day, for each pair of adjacent slots, check if two
302
+ # different tasks from this section occupy them and are in
303
+ # different buildings.
304
+ for day in range(const.NUM_WORKING_DAYS):
305
+ day_offset = day * const.NUM_TEACHING_SLOTS_PER_DAY
306
+
307
+ for slot in range(const.NUM_TEACHING_SLOTS_PER_DAY - 1):
308
+ abs_slot = day_offset + slot
309
+ next_abs_slot = day_offset + slot + 1
310
+
311
+ # Find tasks that could be at abs_slot and next_abs_slot
312
+ # A task covers abs_slot if start <= abs_slot < start + dur
313
+ # For efficiency, we check start_var constraints
314
+
315
+ tasks_at_slot = []
316
+ tasks_at_next = []
317
+
318
+ for task in sec_tasks:
319
+ start_var = self.ce.task_vars[task.task_id][0]
320
+ # Task covers abs_slot if start == abs_slot (for dur=1)
321
+ # or start <= abs_slot < start + dur (for dur>1)
322
+ # We use a bool to detect coverage
323
+ cov = self.model.NewBoolVar(
324
+ f"cov_{task.task_id}_s{abs_slot}")
325
+ self.model.Add(
326
+ start_var <= abs_slot).OnlyEnforceIf(cov)
327
+ self.model.Add(
328
+ start_var + task.duration > abs_slot).OnlyEnforceIf(cov)
329
+ self.model.Add(
330
+ start_var > abs_slot).OnlyEnforceIf(cov.Not())
331
+ tasks_at_slot.append((task, cov))
332
+
333
+ cov_next = self.model.NewBoolVar(
334
+ f"cov_{task.task_id}_s{next_abs_slot}")
335
+ self.model.Add(
336
+ start_var <= next_abs_slot).OnlyEnforceIf(cov_next)
337
+ self.model.Add(
338
+ start_var + task.duration > next_abs_slot
339
+ ).OnlyEnforceIf(cov_next)
340
+ self.model.Add(
341
+ start_var > next_abs_slot
342
+ ).OnlyEnforceIf(cov_next.Not())
343
+ tasks_at_next.append((task, cov_next))
344
+
345
+ # For each pair (one at slot, one at next_slot, different tasks),
346
+ # check if they're in different buildings.
347
+ # To keep this tractable, we only penalize if ANY task at slot
348
+ # and ANY task at next_slot are in different buildings.
349
+ # We approximate: penalize if slot is occupied AND next_slot is
350
+ # occupied (movement required regardless of building).
351
+ # This is a much cheaper O(1)-per-slot approximation.
352
+ if tasks_at_slot and tasks_at_next:
353
+ any_at_slot = self.model.NewBoolVar(
354
+ f"any_{sec_id}_d{day}_s{slot}")
355
+ any_at_next = self.model.NewBoolVar(
356
+ f"any_{sec_id}_d{day}_s{slot + 1}")
357
+
358
+ self.model.AddBoolOr(
359
+ [c for _, c in tasks_at_slot]
360
+ ).OnlyEnforceIf(any_at_slot)
361
+ for _, c in tasks_at_slot:
362
+ self.model.AddImplication(
363
+ any_at_slot.Not(), c.Not())
364
+
365
+ self.model.AddBoolOr(
366
+ [c for _, c in tasks_at_next]
367
+ ).OnlyEnforceIf(any_at_next)
368
+ for _, c in tasks_at_next:
369
+ self.model.AddImplication(
370
+ any_at_next.Not(), c.Not())
371
+
372
+ # Movement penalty: both slots occupied = potential
373
+ # building change (simplified — exact building check
374
+ # was O(n²) and is too expensive for large models)
375
+ both_occupied = self.model.NewBoolVar(
376
+ f"bothocc_{sec_id}_d{day}_s{slot}")
377
+ self.model.AddBoolAnd(
378
+ [any_at_slot, any_at_next]
379
+ ).OnlyEnforceIf(both_occupied)
380
+ self.model.AddBoolOr(
381
+ [any_at_slot.Not(), any_at_next.Not()]
382
+ ).OnlyEnforceIf(both_occupied.Not())
383
+
384
+ # Use a reduced weight since this is an approximation
385
+ self.penalties.append(both_occupied * max(1, weight // 2))
386
 
387
  def _penalize_first_hour_free(self):
388
  """
 
390
  section on any day. The solver will avoid this unless there is genuinely
391
  no other feasible assignment.
392
 
393
+ Rewritten to reuse cached day booleans and daily slot vars for efficiency.
 
394
  """
395
  weight = self.weights.get("no_first_hour_free", 20)
396
  if weight == 0:
397
  return
398
 
399
+ # Group tasks by parent section (merge batches into parent)
400
+ tasks_by_parent = defaultdict(list)
401
  for task in self.tasks:
402
+ sid = task.section.section_id
403
+ parent = sid.split('-')[0] if '-' in sid else sid
404
+ tasks_by_parent[parent].append(task)
405
+
406
+ for sec_id, sec_tasks in tasks_by_parent.items():
407
+ # Ensure day booleans exist
408
+ for task in sec_tasks:
409
+ self._ensure_day_bools(task)
410
 
 
411
  for day in range(const.NUM_WORKING_DAYS):
412
  # The absolute slot index for period 0 of this day
413
  first_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY
 
417
  for task in sec_tasks:
418
  start_var = self.ce.task_vars[task.task_id][0]
419
 
420
+ at_first = self.model.NewBoolVar(
421
+ f"at1st_{task.task_id}_d{day}")
422
+ self.model.Add(
423
+ start_var == first_slot).OnlyEnforceIf(at_first)
424
+ self.model.Add(
425
+ start_var != first_slot).OnlyEnforceIf(at_first.Not())
426
  starts_at_first.append(at_first)
427
 
428
+ if not starts_at_first:
429
+ continue
430
+
431
  # any_at_first = True if at least one task starts at period 0
432
+ any_at_first = self.model.NewBoolVar(
433
+ f"any_at1st_{sec_id}_d{day}")
434
+ self.model.AddBoolOr(
435
+ starts_at_first).OnlyEnforceIf(any_at_first)
436
  for lit in starts_at_first:
437
  self.model.AddImplication(any_at_first.Not(), lit.Not())
438
 
439
  # Penalty when the first hour IS free (no task at period 0)
440
+ first_free = self.model.NewBoolVar(
441
+ f"first_free_{sec_id}_d{day}")
442
+ self.model.Add(
443
+ first_free == 1).OnlyEnforceIf(any_at_first.Not())
444
+ self.model.Add(
445
+ first_free == 0).OnlyEnforceIf(any_at_first)
446
+
447
+ self.penalties.append(first_free * weight)