Spaces:
Sleeping
Sleeping
Update partial_optimizer.py
Browse files- partial_optimizer.py +294 -59
partial_optimizer.py
CHANGED
|
@@ -71,22 +71,61 @@ class PartialOptimizer:
|
|
| 71 |
self.tasks_by_subject[t.subject.subject_code].append(t)
|
| 72 |
|
| 73 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
-
#
|
| 75 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
'FACULTY_SUBSTITUTION': self._op_faculty_substitution,
|
| 91 |
'MOVE_CLASS': self._op_move_class,
|
| 92 |
'SWAP_CLASSES': self._op_swap_classes,
|
|
@@ -98,58 +137,254 @@ class PartialOptimizer:
|
|
| 98 |
'CHANGE_FACULTY': self._op_change_faculty,
|
| 99 |
'FREEZE_SLOT': self._op_freeze_slot,
|
| 100 |
}
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
'FACULTY_FREE_DAY': True,
|
| 106 |
-
'FACULTY_MAX_DAILY_HOURS': True,
|
| 107 |
-
'FACULTY_NO_CONSECUTIVE': True,
|
| 108 |
-
'SECTION_FREE_SLOT': True,
|
| 109 |
-
'WORKING_DAYS': True,
|
| 110 |
-
'SUBJECT_PREFERRED_TIME': True,
|
| 111 |
-
'HEAVY_SUBJECT_MORNING': True,
|
| 112 |
-
'LAB_MUST_CONSECUTIVE': True,
|
| 113 |
-
'NO_BACK_TO_BACK_SUBJECTS': True,
|
| 114 |
-
'DISTRIBUTE_SUBJECTS_EVENLY': True,
|
| 115 |
-
'SUBJECT_SPACING': True,
|
| 116 |
-
'NO_FREE_PERIOD': True,
|
| 117 |
-
}
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
'MOVE_LAB': 'RESCHEDULE_LAB',
|
| 136 |
-
'SHIFT_LAB': 'RESCHEDULE_LAB',
|
| 137 |
-
'LOCK_SLOT': 'FREEZE_SLOT',
|
| 138 |
-
'PIN_SLOT': 'FREEZE_SLOT',
|
| 139 |
-
}
|
| 140 |
-
if ctype in aliases:
|
| 141 |
-
slm_constraint = dict(slm_constraint)
|
| 142 |
-
slm_constraint['type'] = aliases[ctype]
|
| 143 |
-
ctype = aliases[ctype]
|
| 144 |
-
|
| 145 |
-
if ctype in direct_handlers:
|
| 146 |
-
return direct_handlers[ctype](slm_constraint)
|
| 147 |
-
elif ctype in reopt_handlers:
|
| 148 |
return self._reoptimize(slm_constraint, time_limit)
|
| 149 |
else:
|
| 150 |
return ('NO_CHANGE', self.current_solution, [],
|
| 151 |
f'β οΈ Unknown constraint type: {ctype}')
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
# DIRECT MUTATION OPERATIONS
|
| 155 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 71 |
self.tasks_by_subject[t.subject.subject_code].append(t)
|
| 72 |
|
| 73 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
# TYPE CLASSIFICATION HELPERS
|
| 75 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
|
| 77 |
+
# Direct mutation handlers (no CP-SAT needed)
|
| 78 |
+
DIRECT_TYPES = {
|
| 79 |
+
'FACULTY_SUBSTITUTION', 'MOVE_CLASS', 'SWAP_CLASSES',
|
| 80 |
+
'CANCEL_CLASS', 'CHANGE_ROOM', 'ADD_EXTRA_CLASS',
|
| 81 |
+
'MARK_HOLIDAY', 'RESCHEDULE_LAB', 'CHANGE_FACULTY',
|
| 82 |
+
'FREEZE_SLOT',
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# Re-optimization handlers (CP-SAT partial solve)
|
| 86 |
+
REOPT_TYPES = {
|
| 87 |
+
'FACULTY_UNAVAILABLE', 'FACULTY_FREE_DAY',
|
| 88 |
+
'FACULTY_MAX_DAILY_HOURS', 'FACULTY_NO_CONSECUTIVE',
|
| 89 |
+
'SECTION_FREE_SLOT', 'WORKING_DAYS',
|
| 90 |
+
'SUBJECT_PREFERRED_TIME', 'HEAVY_SUBJECT_MORNING',
|
| 91 |
+
'LAB_MUST_CONSECUTIVE', 'NO_BACK_TO_BACK_SUBJECTS',
|
| 92 |
+
'DISTRIBUTE_SUBJECTS_EVENLY', 'SUBJECT_SPACING',
|
| 93 |
+
'NO_FREE_PERIOD',
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# Type aliases β map SLM variants to canonical types
|
| 97 |
+
ALIASES = {
|
| 98 |
+
'SUBJECT_FREE_DAY': 'CANCEL_CLASS',
|
| 99 |
+
'FACULTY_LEAVE': 'FACULTY_SUBSTITUTION',
|
| 100 |
+
'BLOCK_SLOT': 'SECTION_FREE_SLOT',
|
| 101 |
+
'REMOVE_CLASS': 'CANCEL_CLASS',
|
| 102 |
+
'DELETE_CLASS': 'CANCEL_CLASS',
|
| 103 |
+
'CLASS_CANCELLED': 'CANCEL_CLASS',
|
| 104 |
+
'SHIFT_CLASS': 'MOVE_CLASS',
|
| 105 |
+
'RELOCATE_CLASS': 'MOVE_CLASS',
|
| 106 |
+
'TEACHER_SUBSTITUTION': 'FACULTY_SUBSTITUTION',
|
| 107 |
+
'REPLACE_FACULTY': 'FACULTY_SUBSTITUTION',
|
| 108 |
+
'SWAP_FACULTY': 'FACULTY_SUBSTITUTION',
|
| 109 |
+
'SUBJECT_UNAVAILABLE': 'CANCEL_CLASS',
|
| 110 |
+
'NO_CLASS': 'CANCEL_CLASS',
|
| 111 |
+
'HOLIDAY': 'MARK_HOLIDAY',
|
| 112 |
+
'MOVE_LAB': 'RESCHEDULE_LAB',
|
| 113 |
+
'SHIFT_LAB': 'RESCHEDULE_LAB',
|
| 114 |
+
'LOCK_SLOT': 'FREEZE_SLOT',
|
| 115 |
+
'PIN_SLOT': 'FREEZE_SLOT',
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def _resolve_alias(self, constraint: Dict[str, Any]) -> Dict[str, Any]:
|
| 119 |
+
"""Resolve type aliases to canonical constraint types."""
|
| 120 |
+
ctype = constraint.get('type', '').upper()
|
| 121 |
+
if ctype in self.ALIASES:
|
| 122 |
+
constraint = dict(constraint)
|
| 123 |
+
constraint['type'] = self.ALIASES[ctype]
|
| 124 |
+
return constraint
|
| 125 |
+
|
| 126 |
+
def _get_direct_handler(self, ctype: str):
|
| 127 |
+
"""Return the direct-mutation handler function for a constraint type."""
|
| 128 |
+
handlers = {
|
| 129 |
'FACULTY_SUBSTITUTION': self._op_faculty_substitution,
|
| 130 |
'MOVE_CLASS': self._op_move_class,
|
| 131 |
'SWAP_CLASSES': self._op_swap_classes,
|
|
|
|
| 137 |
'CHANGE_FACULTY': self._op_change_faculty,
|
| 138 |
'FREEZE_SLOT': self._op_freeze_slot,
|
| 139 |
}
|
| 140 |
+
return handlers.get(ctype)
|
| 141 |
|
| 142 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
# PUBLIC ENTRY POINT (single constraint β legacy)
|
| 144 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
def apply_constraint_and_reoptimize(
|
| 147 |
+
self,
|
| 148 |
+
slm_constraint: Dict[str, Any],
|
| 149 |
+
time_limit: int = 30
|
| 150 |
+
) -> Tuple[str, Dict[str, Any], List[str], str]:
|
| 151 |
+
"""
|
| 152 |
+
Route to the correct handler based on constraint type.
|
| 153 |
+
Returns: (status, new_solution, affected_task_ids, summary)
|
| 154 |
+
"""
|
| 155 |
+
slm_constraint = self._resolve_alias(slm_constraint)
|
| 156 |
+
ctype = slm_constraint.get('type', '').upper()
|
| 157 |
+
|
| 158 |
+
handler = self._get_direct_handler(ctype)
|
| 159 |
+
if handler:
|
| 160 |
+
return handler(slm_constraint)
|
| 161 |
+
elif ctype in self.REOPT_TYPES:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return self._reoptimize(slm_constraint, time_limit)
|
| 163 |
else:
|
| 164 |
return ('NO_CHANGE', self.current_solution, [],
|
| 165 |
f'β οΈ Unknown constraint type: {ctype}')
|
| 166 |
|
| 167 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
# PUBLIC ENTRY POINT (N constraints β batch)
|
| 169 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 170 |
+
|
| 171 |
+
def apply_constraints_batch(
|
| 172 |
+
self,
|
| 173 |
+
constraints: List[Dict[str, Any]],
|
| 174 |
+
time_limit: int = 30,
|
| 175 |
+
) -> Tuple[str, Dict[str, Any], List[str], List[str]]:
|
| 176 |
+
"""
|
| 177 |
+
Apply N constraints together. Direct-mutation constraints are
|
| 178 |
+
applied immediately in sequence; all re-optimization constraints
|
| 179 |
+
are batched into a SINGLE CP-SAT solve so the solver sees them
|
| 180 |
+
simultaneously (avoiding sequential conflicts).
|
| 181 |
+
|
| 182 |
+
Returns: (status, new_solution, affected_task_ids, change_summaries)
|
| 183 |
+
"""
|
| 184 |
+
if not constraints:
|
| 185 |
+
return ('NO_CHANGE', self.current_solution, [], ['No constraints provided.'])
|
| 186 |
+
|
| 187 |
+
# ββ 1. Resolve aliases ββββββββββββββββββββββββββββββββββββββββββ
|
| 188 |
+
resolved = [self._resolve_alias(c) for c in constraints]
|
| 189 |
+
|
| 190 |
+
# ββ 2. Classify into direct vs. reopt βββββββββββββββββββββββββββ
|
| 191 |
+
direct_constraints = []
|
| 192 |
+
reopt_constraints = []
|
| 193 |
+
unknown_types = []
|
| 194 |
+
for c in resolved:
|
| 195 |
+
ctype = c.get('type', '').upper()
|
| 196 |
+
if ctype in self.DIRECT_TYPES:
|
| 197 |
+
direct_constraints.append(c)
|
| 198 |
+
elif ctype in self.REOPT_TYPES:
|
| 199 |
+
reopt_constraints.append(c)
|
| 200 |
+
else:
|
| 201 |
+
unknown_types.append(ctype)
|
| 202 |
+
|
| 203 |
+
all_changes: List[str] = []
|
| 204 |
+
final_solution = self.current_solution
|
| 205 |
+
overall_status = 'NO_CHANGE'
|
| 206 |
+
|
| 207 |
+
# ββ 3. Apply direct-mutation constraints sequentially βββββββββββ
|
| 208 |
+
# (These don't need CP-SAT; they mutate the schedule dict
|
| 209 |
+
# directly. Each sees the result of the previous one.)
|
| 210 |
+
for c in direct_constraints:
|
| 211 |
+
ctype = c.get('type', '').upper()
|
| 212 |
+
handler = self._get_direct_handler(ctype)
|
| 213 |
+
if handler:
|
| 214 |
+
# Update self.current_solution so the handler sees latest state
|
| 215 |
+
self.current_solution = final_solution
|
| 216 |
+
status, new_sol, _, summary = handler(c)
|
| 217 |
+
if status in ('OPTIMAL', 'FEASIBLE'):
|
| 218 |
+
final_solution = new_sol
|
| 219 |
+
overall_status = 'FEASIBLE'
|
| 220 |
+
all_changes.append(summary)
|
| 221 |
+
|
| 222 |
+
# ββ 4. Batch all re-optimization constraints into ONE solve βββββ
|
| 223 |
+
if reopt_constraints:
|
| 224 |
+
# Update solution reference for the reopt phase
|
| 225 |
+
self.current_solution = final_solution
|
| 226 |
+
|
| 227 |
+
# Detect conflicts before solving
|
| 228 |
+
conflicts = self.detect_conflicts(reopt_constraints)
|
| 229 |
+
if conflicts:
|
| 230 |
+
all_changes.append(
|
| 231 |
+
f"β οΈ Potential conflicts detected:\n"
|
| 232 |
+
+ "\n".join(f" β’ {cf}" for cf in conflicts)
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Collect affected tasks across ALL reopt constraints
|
| 236 |
+
all_affected = set()
|
| 237 |
+
for c in reopt_constraints:
|
| 238 |
+
affected = self._find_affected_tasks(c)
|
| 239 |
+
all_affected.update(affected)
|
| 240 |
+
|
| 241 |
+
if all_affected:
|
| 242 |
+
# Scale time limit based on constraint count and task count
|
| 243 |
+
scaled_limit = time_limit + (len(reopt_constraints) * 10)
|
| 244 |
+
scaled_limit = min(scaled_limit, 300) # cap at 5 minutes
|
| 245 |
+
|
| 246 |
+
status, partial = self._solve_partial(
|
| 247 |
+
list(all_affected), reopt_constraints, scaled_limit
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
if status in ('OPTIMAL', 'FEASIBLE'):
|
| 251 |
+
final_solution = dict(final_solution)
|
| 252 |
+
final_solution.update(partial)
|
| 253 |
+
overall_status = status
|
| 254 |
+
|
| 255 |
+
# Build change summaries
|
| 256 |
+
changes = []
|
| 257 |
+
for tid in all_affected:
|
| 258 |
+
old = self.current_solution.get(tid, {})
|
| 259 |
+
new = final_solution.get(tid, {})
|
| 260 |
+
if old and new:
|
| 261 |
+
old_slot = f"{const.DAYS[old['day_index']]} P{old['period_index']+1}"
|
| 262 |
+
new_slot = f"{const.DAYS[new['day_index']]} P{new['period_index']+1}"
|
| 263 |
+
if old_slot != new_slot:
|
| 264 |
+
changes.append(
|
| 265 |
+
f"β’ {new.get('subject_code','?').upper()} "
|
| 266 |
+
f"({new.get('section_id','?')}): "
|
| 267 |
+
f"{old_slot} β {new_slot}"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
summary = (
|
| 271 |
+
f"β
Batch: {len(reopt_constraints)} constraint(s), "
|
| 272 |
+
f"{len(changes)} slot(s) moved:\n" + "\n".join(changes)
|
| 273 |
+
if changes else
|
| 274 |
+
f"β
{len(reopt_constraints)} constraint(s) applied β no slot changes needed."
|
| 275 |
+
)
|
| 276 |
+
all_changes.append(summary)
|
| 277 |
+
else:
|
| 278 |
+
all_changes.append(
|
| 279 |
+
f"β Could not satisfy {len(reopt_constraints)} batched "
|
| 280 |
+
f"constraint(s) affecting {len(all_affected)} task(s). "
|
| 281 |
+
f"Constraints may be too restrictive or mutually conflicting."
|
| 282 |
+
)
|
| 283 |
+
overall_status = status
|
| 284 |
+
else:
|
| 285 |
+
all_changes.append(
|
| 286 |
+
f"βΉοΈ {len(reopt_constraints)} re-optimization constraint(s) "
|
| 287 |
+
f"matched no affected tasks."
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# ββ 5. Report unknown types βββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
for utype in unknown_types:
|
| 292 |
+
all_changes.append(f"β οΈ Unknown constraint type: {utype}")
|
| 293 |
+
|
| 294 |
+
return (overall_status, final_solution, [], all_changes)
|
| 295 |
+
|
| 296 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 297 |
+
# CONFLICT DETECTION
|
| 298 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 299 |
+
|
| 300 |
+
def detect_conflicts(
|
| 301 |
+
self, constraints: List[Dict[str, Any]]
|
| 302 |
+
) -> List[str]:
|
| 303 |
+
"""
|
| 304 |
+
Detect obvious conflicts between constraints BEFORE solving.
|
| 305 |
+
Returns a list of human-readable conflict descriptions.
|
| 306 |
+
Does not block solving β just warns the user.
|
| 307 |
+
"""
|
| 308 |
+
conflicts = []
|
| 309 |
+
|
| 310 |
+
# Index constraints by faculty and section for cross-checking
|
| 311 |
+
faculty_constraints = defaultdict(list)
|
| 312 |
+
section_constraints = defaultdict(list)
|
| 313 |
+
day_constraints = defaultdict(list)
|
| 314 |
+
|
| 315 |
+
for i, c in enumerate(constraints):
|
| 316 |
+
ctype = c.get('type', '').upper()
|
| 317 |
+
fid = c.get('faculty_id')
|
| 318 |
+
sid = c.get('section_id')
|
| 319 |
+
days = c.get('days') or []
|
| 320 |
+
|
| 321 |
+
if fid:
|
| 322 |
+
faculty_constraints[fid].append((i, c))
|
| 323 |
+
if sid:
|
| 324 |
+
section_constraints[sid].append((i, c))
|
| 325 |
+
for d in days:
|
| 326 |
+
day_constraints[d].append((i, c))
|
| 327 |
+
|
| 328 |
+
# Check: Faculty made unavailable on a day where another
|
| 329 |
+
# constraint tries to move their class TO that day
|
| 330 |
+
for fid, fac_cs in faculty_constraints.items():
|
| 331 |
+
unavailable_days = set()
|
| 332 |
+
move_to_days = set()
|
| 333 |
+
for _, c in fac_cs:
|
| 334 |
+
ctype = c.get('type', '').upper()
|
| 335 |
+
if ctype == 'FACULTY_UNAVAILABLE':
|
| 336 |
+
for d in (c.get('days') or []):
|
| 337 |
+
unavailable_days.add(d)
|
| 338 |
+
if ctype == 'SUBJECT_PREFERRED_TIME':
|
| 339 |
+
# Check if we're trying to move this faculty's
|
| 340 |
+
# subject when they're also being made unavailable
|
| 341 |
+
move_to_days.add(c.get('subject_code', '?'))
|
| 342 |
+
|
| 343 |
+
if unavailable_days and move_to_days:
|
| 344 |
+
fac_name = self.fac_by_id.get(fid)
|
| 345 |
+
fac_label = fac_name.name if fac_name else fid
|
| 346 |
+
conflicts.append(
|
| 347 |
+
f"Faculty '{fac_label}' is made unavailable on "
|
| 348 |
+
f"{', '.join(unavailable_days)} but other constraints "
|
| 349 |
+
f"affect their subjects ({', '.join(move_to_days)})"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# Check: Section has conflicting free-slot and no-free-period
|
| 353 |
+
for sid, sec_cs in section_constraints.items():
|
| 354 |
+
free_slots = set()
|
| 355 |
+
no_free_periods = set()
|
| 356 |
+
for _, c in sec_cs:
|
| 357 |
+
ctype = c.get('type', '').upper()
|
| 358 |
+
if ctype == 'SECTION_FREE_SLOT':
|
| 359 |
+
slot = c.get('slot')
|
| 360 |
+
if slot is not None:
|
| 361 |
+
free_slots.add(slot - 1) # 0-indexed
|
| 362 |
+
if ctype == 'NO_FREE_PERIOD':
|
| 363 |
+
for p in (c.get('periods') or []):
|
| 364 |
+
no_free_periods.add(p)
|
| 365 |
+
|
| 366 |
+
overlap = free_slots & no_free_periods
|
| 367 |
+
if overlap:
|
| 368 |
+
conflicts.append(
|
| 369 |
+
f"Section '{sid}': period(s) {[p+1 for p in overlap]} "
|
| 370 |
+
f"are set as BOTH free-slot AND no-free-period"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
# Check: Working-day restrictions that eliminate too many days
|
| 374 |
+
for fid, fac_cs in faculty_constraints.items():
|
| 375 |
+
for _, c in fac_cs:
|
| 376 |
+
if c.get('type', '').upper() == 'WORKING_DAYS':
|
| 377 |
+
allowed = c.get('days') or []
|
| 378 |
+
if len(allowed) < 2:
|
| 379 |
+
fac_name = self.fac_by_id.get(fid)
|
| 380 |
+
fac_label = fac_name.name if fac_name else fid
|
| 381 |
+
conflicts.append(
|
| 382 |
+
f"Working-days constraint for '{fac_label}' allows "
|
| 383 |
+
f"only {len(allowed)} day(s) β likely too restrictive"
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
return conflicts
|
| 387 |
+
|
| 388 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
# DIRECT MUTATION OPERATIONS
|
| 390 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|