KindAlien commited on
Commit
5ba1eb2
·
verified ·
1 Parent(s): 8385c96

Update objective_engine.py

Browse files
Files changed (1) hide show
  1. objective_engine.py +313 -227
objective_engine.py CHANGED
@@ -1,253 +1,339 @@
1
- # main.py
2
 
3
  """
4
- This is the main entry point for the VTU Automated Timetable Generator.
 
5
 
6
- It performs the following steps:
7
- 1. Defines sample academic data (Faculties, Subjects, Sections, Rooms).
8
- 2. Uses the data_loader to convert this data into atomic 'Task' objects.
9
- 3. Initializes the TimetableSolver.
10
- 4. Runs the solver to generate a valid timetable.
11
- 5. Prints the resulting schedule in a human-readable grid format.
12
- 6. (Optional) Demonstrates the Emergency Re-optimizer functionality.
13
 
14
- This file is a standalone script and can be replaced by a UI or API layer.
15
  """
16
 
17
- import sys
18
- from typing import List, Dict, Any
 
19
 
20
- # Import project modules
21
- from models import Faculty, Subject, Section, Room, SubjectType
22
- from data_loader import Allocation, prepare_scheduling_tasks
23
- from solver import TimetableSolver
24
- from reoptimizer import EmergencyReoptimizer
25
  import constants as const
 
 
26
 
27
- def create_sample_data():
28
  """
29
- Creates mock data for a Computer Science department (3rd & 5th Semester).
30
  """
31
- print("Creating sample data...")
32
-
33
- # --- 1. Faculties ---
34
- # Define a mix of senior and junior faculties
35
- faculties = [
36
- Faculty("F01", "Dr. Alice", "Professor", max_hours_per_week=12),
37
- Faculty("F02", "Prof. Bob", "Assoc. Prof", max_hours_per_week=16),
38
- Faculty("F03", "Prof. Charlie", "Asst. Prof", max_hours_per_week=18),
39
- Faculty("F04", "Prof. Dave", "Asst. Prof", max_hours_per_week=18),
40
- Faculty("F05", "Prof. Eve", "Asst. Prof", max_hours_per_week=18),
41
- Faculty("F06", "Guest Fac", "Guest", max_hours_per_week=8),
42
- ]
43
-
44
- # --- 2. Subjects ---
45
- # Core Subjects, Labs, and Electives
46
- subjects = [
47
- # 5th Sem
48
- Subject("CS51", "Mgmt & Entrepren", 3, SubjectType.THEORY),
49
- Subject("CS52", "Computer Networks", 4, SubjectType.THEORY, is_core=True, is_heavy=True),
50
- Subject("CS53", "Database Mgmt", 4, SubjectType.THEORY, is_core=True, is_heavy=True),
51
- Subject("CS54", "Automata Theory", 3, SubjectType.THEORY, is_core=True),
52
- Subject("CS55", "Python Elective", 3, SubjectType.THEORY), # Elective
53
- Subject("CS56", "Java Elective", 3, SubjectType.THEORY), # Elective
54
- Subject("CSL57", "Networks Lab", 1, SubjectType.LAB), # 2-hour block
55
- Subject("CSL58", "DBMS Lab", 1, SubjectType.LAB), # 2-hour block
56
-
57
- # 3rd Sem
58
- Subject("CS31", "Maths III", 3, SubjectType.THEORY, is_core=True),
59
- Subject("CS32", "Data Structures", 4, SubjectType.THEORY, is_core=True, is_heavy=True),
60
- Subject("CS33", "Analog Digital", 3, SubjectType.THEORY),
61
- Subject("CS34", "COA", 3, SubjectType.THEORY),
62
- Subject("CSL37", "DS Lab", 1, SubjectType.LAB),
63
- Subject("CSL38", "AD Lab", 1, SubjectType.LAB),
64
- ]
65
-
66
- # --- 3. Sections ---
67
- sections = [
68
- Section("5A", 5, 60),
69
- Section("5B", 5, 60),
70
- Section("3A", 3, 65),
71
- ]
72
-
73
- # --- 4. Rooms ---
74
- rooms = [
75
- # Classrooms
76
- Room("R101", 70, is_lab=False, building="Main Block"),
77
- Room("R102", 70, is_lab=False, building="Main Block"),
78
- Room("R103", 70, is_lab=False, building="Main Block"),
79
- # Labs
80
- Room("LAB1", 30, is_lab=True, building="Lab Block"), # Small lab
81
- Room("LAB2", 70, is_lab=True, building="Lab Block"), # Big lab
82
- ]
83
-
84
- # --- 5. Allocations (Who teaches what to whom) ---
85
- allocations = [
86
- # --- 5th Sem Section A ---
87
- Allocation("F01", "CS51", "5A"),
88
- Allocation("F02", "CS52", "5A"),
89
- Allocation("F03", "CS53", "5A"),
90
- Allocation("F04", "CS54", "5A"),
91
- # Elective: Group 1 (Split class)
92
- Allocation("F05", "CS55", "5A", elective_group_id="ELEC_5_GRP1"),
93
- # Labs
94
- Allocation("F02", "CSL57", "5A"),
95
- Allocation("F03", "CSL58", "5A"),
96
-
97
- # --- 5th Sem Section B ---
98
- Allocation("F01", "CS51", "5B"),
99
- Allocation("F02", "CS52", "5B"),
100
- Allocation("F03", "CS53", "5B"),
101
- Allocation("F04", "CS54", "5B"),
102
- # Elective: Same Group ID to align slot (if cross-section) or different if purely parallel
103
- # Here we assume 5A and 5B might have electives at same time
104
- Allocation("F06", "CS56", "5B", elective_group_id="ELEC_5_GRP1"),
105
- # Labs
106
- Allocation("F02", "CSL57", "5B"),
107
- Allocation("F03", "CSL58", "5B"),
108
-
109
- # --- 3rd Sem Section A ---
110
- Allocation("F04", "CS31", "3A"),
111
- Allocation("F05", "CS32", "3A"),
112
- Allocation("F06", "CS33", "3A"),
113
- Allocation("F01", "CS34", "3A"),
114
- Allocation("F05", "CSL37", "3A"),
115
- Allocation("F06", "CSL38", "3A"),
116
- ]
117
-
118
- return faculties, subjects, sections, rooms, allocations
119
-
120
-
121
- def print_timetable_grid(solution: Dict[str, Any], sections: List[Section]):
122
- """
123
- Prints the generated timetable with explicit Break and Lunch columns.
124
- """
125
- if not solution:
126
- print("No solution to display.")
127
- return
128
-
129
- # 1. Organize data into a nested dictionary
130
- # Structure: grid[section_id][day_index][period_index] = "Subject (Faculty)"
131
- grid = {sec.section_id: {d: {} for d in range(const.NUM_WORKING_DAYS)} for sec in sections}
132
-
133
- for task_id, info in solution.items():
134
- sec_id = info['section_id']
135
- day = info['day_index']
136
- start_period = info['period_index']
137
- duration = info['duration']
138
-
139
- # Format the label
140
- # e.g., "NLP (Anu) [R1]"
141
- label = f"{info['subject_code']} ({info['faculty_name']}) [{info['room_id']}]"
142
 
143
- for i in range(duration):
144
- current_period = start_period + i
145
- if current_period < const.NUM_TEACHING_SLOTS_PER_DAY:
146
- grid[sec_id][day][current_period] = label
147
-
148
- # 2. Print the Grid
149
- for sec in sections:
150
- print(f"\n{'='*100}")
151
- print(f"TIMETABLE FOR SECTION: {sec.section_id}")
152
- print(f"{'='*100}")
153
-
154
- # --- Build Header Row ---
155
- header = f"{'DAY':<10} |"
156
- separator = f"{'-'*10}-+"
157
 
158
- for i in range(const.NUM_TEACHING_SLOTS_PER_DAY):
159
- # Print Period Number
160
- header += f" P{i+1:<13} |"
161
- separator += f"{'-'*15}-+"
162
-
163
- # Inject Break Header
164
- if i == const.BREAK_AFTER_INDEX:
165
- header += " BREAK (15m) |"
166
- separator += f"{'-'*13}-+"
167
- # Inject Lunch Header
168
- elif i == const.LUNCH_AFTER_INDEX:
169
- header += " LUNCH (1h) |"
170
- separator += f"{'-'*13}-+"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
- print(header)
173
- print(separator)
174
 
175
- # --- Build Data Rows ---
176
- for d_idx, day_name in enumerate(const.DAYS):
177
- row = f"{day_name:<10} |"
178
-
179
- for p_idx in range(const.NUM_TEACHING_SLOTS_PER_DAY):
180
- # Get the class info, default to empty
181
- cell_data = grid[sec.section_id][d_idx].get(p_idx, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- # Truncate to fit column
184
- row += f" {cell_data[:13]:<13} |"
185
 
186
- # Inject Break Column
187
- if p_idx == const.BREAK_AFTER_INDEX:
188
- row += f" {'***':<11} |"
189
- # Inject Lunch Column
190
- elif p_idx == const.LUNCH_AFTER_INDEX:
191
- row += f" {'---':<11} |"
192
-
193
- print(row)
194
- print(separator)
195
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- def main():
198
- # 1. Load Data
199
- faculties, subjects, sections, rooms, allocations = create_sample_data()
200
 
201
- # 2. Prepare Tasks
202
- print(f"Generating tasks from {len(allocations)} allocations...")
203
- tasks = prepare_scheduling_tasks(allocations, faculties, subjects, sections)
204
- print(f"Total atomic tasks to schedule: {len(tasks)}")
 
 
 
 
 
205
 
206
- # 3. Initialize Solver
207
- solver = TimetableSolver(tasks, faculties, sections, rooms)
 
 
 
 
208
 
209
- # 4. Solve
210
- print("\nRunning Solver...")
211
- status, solution = solver.solve(
212
- time_limit_seconds=10,
213
- enable_soft_constraints=True,
214
- soft_constraint_weights={
215
- "subject_repetition": 10,
216
- "morning_core": 5,
217
- "late_heavy": 10,
218
- "faculty_gaps": 2,
219
- "campus_movement": 5,
220
- "no_first_hour_free": 20
221
- }
222
- )
223
 
224
- if status in ["OPTIMAL", "FEASIBLE"]:
225
- # 5. Display Results
226
- print_timetable_grid(solution, sections)
227
-
228
- # 6. Emergency Re-optimization Demo
229
- print("\n" + "!"*80)
230
- print("SIMULATING EMERGENCY: Faculty 'Prof. Bob' (F02) takes leave on Tuesday.")
231
- print("!"*80)
232
-
233
- reoptimizer = EmergencyReoptimizer(tasks, faculties, sections, rooms)
234
-
235
- # Tuesday is index 1
236
- reopt_status, new_solution = reoptimizer.reoptimize_for_faculty_leave(
237
- current_schedule=solution,
238
- faculty_id="F02",
239
- leave_day_index=1,
240
- time_limit_seconds=10
241
- )
242
 
243
- if reopt_status in ["OPTIMAL", "FEASIBLE"]:
244
- print("\nRe-optimized Timetable (Changes minimized):")
245
- print_timetable_grid(new_solution, sections)
246
- else:
247
- print("Failed to re-optimize.")
 
 
 
 
 
248
 
249
- else:
250
- print(f"Solver failed to find a solution. Status: {status}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- if __name__ == "__main__":
253
- main()
 
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,
32
+ constraint_engine: ConstraintEngine,
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,
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
+ self._minimize_faculty_gaps()
72
+ self._minimize_campus_movement()
73
+ self._penalize_first_hour_free()
74
+
75
+ # Summation of all penalties
76
+ if self.penalties:
77
+ total_cost = sum(self.penalties)
78
+ self.model.Minimize(total_cost)
79
+ else:
80
+ self.model.Minimize(0)
81
+
82
+ def _minimize_subject_repetition(self):
83
+ """
84
+ Penalizes scheduling the same theory subject multiple times on the same day
85
+ for a specific section.
86
+ """
87
+ weight = self.weights.get("subject_repetition", 0)
88
+ if weight == 0: return
89
+
90
+ # Group tasks by (section, subject)
91
+ tasks_by_sec_sub = {}
92
+ for task in self.tasks:
93
+ if task.subject.subject_type == SubjectType.THEORY:
94
+ key = (task.section.section_id, task.subject.subject_code)
95
+ if key not in tasks_by_sec_sub:
96
+ tasks_by_sec_sub[key] = []
97
+ tasks_by_sec_sub[key].append(task)
98
+
99
+ for (sec_id, sub_code), subject_tasks in tasks_by_sec_sub.items():
100
+ if len(subject_tasks) < 2:
101
+ continue
102
+
103
+ # Compare every pair
104
+ for i in range(len(subject_tasks)):
105
+ for j in range(i + 1, len(subject_tasks)):
106
+ t1 = subject_tasks[i]
107
+ t2 = subject_tasks[j]
108
+
109
+ start_var_1 = self.ce.task_vars[t1.task_id][0]
110
+ start_var_2 = self.ce.task_vars[t2.task_id][0]
111
+
112
+ # Create variables representing the day index (0-4)
113
+ day_1 = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"day_{t1.task_id}")
114
+ day_2 = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"day_{t2.task_id}")
115
+
116
+ # Helper: day = start_slot // slots_per_day
117
+ self.model.AddDivisionEquality(day_1, start_var_1, const.NUM_TEACHING_SLOTS_PER_DAY)
118
+ self.model.AddDivisionEquality(day_2, start_var_2, const.NUM_TEACHING_SLOTS_PER_DAY)
119
+
120
+ # Reify: are they on the same day?
121
+ same_day = self.model.NewBoolVar(f"same_day_{t1.task_id}_{t2.task_id}")
122
+ self.model.Add(day_1 == day_2).OnlyEnforceIf(same_day)
123
+ self.model.Add(day_1 != day_2).OnlyEnforceIf(same_day.Not())
124
+
125
+ # Add penalty
126
+ self.penalties.append(same_day * weight)
127
+
128
+ def _prioritize_morning_core_subjects(self):
129
+ """
130
+ Penalizes Core subjects if they are scheduled after the lunch break.
131
+ """
132
+ weight = self.weights.get("morning_core", 0)
133
+ if weight == 0: return
134
+
135
+ # Assume slots 0-3 are morning, 4-7 are afternoon
136
+ afternoon_start_index = 4
137
+
138
+ for task in self.tasks:
139
+ if task.subject.is_core and task.subject.subject_type == SubjectType.THEORY:
140
+ start_var = self.ce.task_vars[task.task_id][0]
141
 
142
+ daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_{task.task_id}")
143
+ self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
144
 
145
+ # Penalty if daily_slot >= afternoon_start_index
146
+ is_afternoon = self.model.NewBoolVar(f"is_afternoon_{task.task_id}")
147
+ self.model.Add(daily_slot >= afternoon_start_index).OnlyEnforceIf(is_afternoon)
148
+ self.model.Add(daily_slot < afternoon_start_index).OnlyEnforceIf(is_afternoon.Not())
149
+
150
+ self.penalties.append(is_afternoon * weight)
151
+
152
+ def _avoid_late_heavy_subjects(self):
153
+ """
154
+ Penalizes Heavy subjects if they are scheduled in the very last slot of the day.
155
+ """
156
+ weight = self.weights.get("late_heavy", 0)
157
+ if weight == 0: return
158
+
159
+ last_slot_index = const.NUM_TEACHING_SLOTS_PER_DAY - 1
160
+
161
+ for task in self.tasks:
162
+ if task.subject.is_heavy:
163
+ start_var = self.ce.task_vars[task.task_id][0]
164
+
165
+ daily_slot = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY - 1, f"daily_slot_heavy_{task.task_id}")
166
+ self.model.AddModuloEquality(daily_slot, start_var, const.NUM_TEACHING_SLOTS_PER_DAY)
167
+
168
+ is_last_slot = self.model.NewBoolVar(f"is_last_slot_{task.task_id}")
169
+ self.model.Add(daily_slot == last_slot_index).OnlyEnforceIf(is_last_slot)
170
+ self.model.Add(daily_slot != last_slot_index).OnlyEnforceIf(is_last_slot.Not())
171
+
172
+ self.penalties.append(is_last_slot * weight)
173
+
174
+ def _minimize_faculty_gaps(self):
175
+ """
176
+ Penalizes 'idle spans' for faculty.
177
+ We approximate this by minimizing (Daily End Time - Daily Start Time - Total Teaching Duration).
178
+ """
179
+ weight = self.weights.get("faculty_gaps", 0)
180
+ if weight == 0: return
181
+
182
+ # Group tasks by faculty
183
+ tasks_by_faculty = {f.id: [] for f in self.faculties}
184
+ for task in self.tasks:
185
+ tasks_by_faculty[task.faculty.id].append(task)
186
+
187
+ for faculty_id, f_tasks in tasks_by_faculty.items():
188
+ if not f_tasks:
189
+ continue
190
+
191
+ for day in range(const.NUM_WORKING_DAYS):
192
+ day_offset_start = day * const.NUM_TEACHING_SLOTS_PER_DAY
193
+ day_offset_end = (day + 1) * const.NUM_TEACHING_SLOTS_PER_DAY
194
+
195
+ # Variables to track if faculty is active on this day, and their start/end
196
+ day_active = self.model.NewBoolVar(f"active_{faculty_id}_{day}")
197
+ day_start = self.model.NewIntVar(day_offset_start, day_offset_end, f"start_{faculty_id}_{day}")
198
+ day_end = self.model.NewIntVar(day_offset_start, day_offset_end, f"end_{faculty_id}_{day}")
199
 
200
+ task_on_day_lits = []
201
+ total_duration_on_day = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"dur_{faculty_id}_{day}")
202
 
203
+ durations_sum = []
 
 
 
 
 
 
 
 
204
 
205
+ for task in f_tasks:
206
+ t_start = self.ce.task_vars[task.task_id][0]
207
+ t_end = self.ce.task_vars[task.task_id][1]
208
+
209
+ is_on_day = self.model.NewBoolVar(f"{task.task_id}_on_day_{day}")
210
+
211
+ t_day = self.model.NewIntVar(0, const.NUM_WORKING_DAYS - 1, f"t_day_{task.task_id}_{day}")
212
+ self.model.AddDivisionEquality(t_day, t_start, const.NUM_TEACHING_SLOTS_PER_DAY)
213
+
214
+ self.model.Add(t_day == day).OnlyEnforceIf(is_on_day)
215
+ self.model.Add(t_day != day).OnlyEnforceIf(is_on_day.Not())
216
 
217
+ task_on_day_lits.append(is_on_day)
 
 
218
 
219
+ # Update min start and max end for the day ONLY if task is on this day
220
+ self.model.Add(day_start <= t_start).OnlyEnforceIf(is_on_day)
221
+ self.model.Add(day_end >= t_end).OnlyEnforceIf(is_on_day)
222
+
223
+ # Accumulate duration
224
+ dur_term = self.model.NewIntVar(0, task.duration, f"dur_term_{task.task_id}_{day}")
225
+ self.model.Add(dur_term == task.duration).OnlyEnforceIf(is_on_day)
226
+ self.model.Add(dur_term == 0).OnlyEnforceIf(is_on_day.Not())
227
+ durations_sum.append(dur_term)
228
 
229
+ # If no tasks on this day, force active to false
230
+ self.model.Add(sum(task_on_day_lits) > 0).OnlyEnforceIf(day_active)
231
+ self.model.Add(sum(task_on_day_lits) == 0).OnlyEnforceIf(day_active.Not())
232
+
233
+ # --- FIX: Use Python sum() inside Add() instead of self.model.Sum() ---
234
+ self.model.Add(total_duration_on_day == sum(durations_sum))
235
 
236
+ span = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"span_{faculty_id}_{day}")
237
+ self.model.Add(span == day_end - day_start).OnlyEnforceIf(day_active)
238
+ self.model.Add(span == 0).OnlyEnforceIf(day_active.Not())
239
+
240
+ idle_time = self.model.NewIntVar(0, const.NUM_TEACHING_SLOTS_PER_DAY, f"idle_{faculty_id}_{day}")
241
+ self.model.Add(idle_time == span - total_duration_on_day).OnlyEnforceIf(day_active)
242
+ self.model.Add(idle_time == 0).OnlyEnforceIf(day_active.Not())
 
 
 
 
 
 
 
243
 
244
+ self.penalties.append(idle_time * weight)
245
+
246
+ def _minimize_campus_movement(self):
247
+ """
248
+ Penalizes consecutive tasks for a section that are in different buildings.
249
+ """
250
+ weight = self.weights.get("campus_movement", 0)
251
+ if weight == 0: return
 
 
 
 
 
 
 
 
 
 
252
 
253
+ unique_buildings = sorted(list(set(r.building for r in self.rooms)))
254
+ building_to_id = {b: i for i, b in enumerate(unique_buildings)}
255
+ room_idx_to_building_id = [building_to_id[r.building] for r in self.rooms]
256
+
257
+ tasks_by_section = defaultdict(list)
258
+ for task in self.tasks:
259
+ tasks_by_section[task.section.section_id].append(task)
260
+
261
+ for sec_id, sec_tasks in tasks_by_section.items():
262
+ if len(sec_tasks) < 2: continue
263
 
264
+ for i in range(len(sec_tasks)):
265
+ for j in range(len(sec_tasks)):
266
+ if i == j: continue
267
+ t1 = sec_tasks[i]
268
+ t2 = sec_tasks[j]
269
+
270
+ t1_end = self.ce.task_vars[t1.task_id][1]
271
+ t2_start = self.ce.task_vars[t2.task_id][0]
272
+
273
+ is_consecutive = self.model.NewBoolVar(f"consec_{t1.task_id}_{t2.task_id}")
274
+ self.model.Add(t1_end == t2_start).OnlyEnforceIf(is_consecutive)
275
+ self.model.Add(t1_end != t2_start).OnlyEnforceIf(is_consecutive.Not())
276
+
277
+ b1_var = self.model.NewIntVar(0, len(unique_buildings), f"bld_{t1.task_id}")
278
+ b2_var = self.model.NewIntVar(0, len(unique_buildings), f"bld_{t2.task_id}")
279
+
280
+ room_var_1 = self.ce.task_vars[t1.task_id][3]
281
+ room_var_2 = self.ce.task_vars[t2.task_id][3]
282
+
283
+ self.model.AddElement(room_var_1, room_idx_to_building_id, b1_var)
284
+ self.model.AddElement(room_var_2, room_idx_to_building_id, b2_var)
285
+
286
+ diff_building = self.model.NewBoolVar(f"diff_bld_{t1.task_id}_{t2.task_id}")
287
+ self.model.Add(b1_var != b2_var).OnlyEnforceIf(diff_building)
288
+ self.model.Add(b1_var == b2_var).OnlyEnforceIf(diff_building.Not())
289
+
290
+ penalty_active = self.model.NewBoolVar(f"move_pen_{t1.task_id}_{t2.task_id}")
291
+ self.model.AddBoolAnd([is_consecutive, diff_building]).OnlyEnforceIf(penalty_active)
292
+
293
+ self.penalties.append(penalty_active * weight)
294
+
295
+ def _penalize_first_hour_free(self):
296
+ """
297
+ Strongly penalizes having the first hour (period index 0) free for any
298
+ section on any day. The solver will avoid this unless there is genuinely
299
+ no other feasible assignment.
300
+
301
+ Since tasks never cross day boundaries, a task covers period 0 of a day
302
+ if and only if its start_var equals that day's first absolute slot index.
303
+ """
304
+ weight = self.weights.get("no_first_hour_free", 20)
305
+ if weight == 0:
306
+ return
307
+
308
+ # Group tasks by section
309
+ tasks_by_section = defaultdict(list)
310
+ for task in self.tasks:
311
+ tasks_by_section[task.section.section_id].append(task)
312
+
313
+ for sec_id, sec_tasks in tasks_by_section.items():
314
+ for day in range(const.NUM_WORKING_DAYS):
315
+ # The absolute slot index for period 0 of this day
316
+ first_slot = day * const.NUM_TEACHING_SLOTS_PER_DAY
317
+
318
+ # For each task, create a bool: does it start at exactly first_slot?
319
+ starts_at_first = []
320
+ for task in sec_tasks:
321
+ start_var = self.ce.task_vars[task.task_id][0]
322
+
323
+ at_first = self.model.NewBoolVar(f"at1st_{task.task_id}_d{day}")
324
+ self.model.Add(start_var == first_slot).OnlyEnforceIf(at_first)
325
+ self.model.Add(start_var != first_slot).OnlyEnforceIf(at_first.Not())
326
+ starts_at_first.append(at_first)
327
+
328
+ # any_at_first = True if at least one task starts at period 0
329
+ any_at_first = self.model.NewBoolVar(f"any_at1st_{sec_id}_d{day}")
330
+ self.model.AddBoolOr(starts_at_first).OnlyEnforceIf(any_at_first)
331
+ for lit in starts_at_first:
332
+ self.model.AddImplication(any_at_first.Not(), lit.Not())
333
+
334
+ # Penalty when the first hour IS free (no task at period 0)
335
+ first_free = self.model.NewBoolVar(f"first_free_{sec_id}_d{day}")
336
+ self.model.Add(first_free == 1).OnlyEnforceIf(any_at_first.Not())
337
+ self.model.Add(first_free == 0).OnlyEnforceIf(any_at_first)
338
 
339
+ self.penalties.append(first_free * weight)