Skip to content

Commit ac591e4

Browse files
committed
Add new faculty constraints; prevent course section overlaps
Signed-off-by: Will Killian <william.killian@outlook.com>
1 parent f5a6ae7 commit ac591e4

4 files changed

Lines changed: 137 additions & 9 deletions

File tree

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ The configuration system uses several type aliases for validation:
5959
"maximum_credits": 12,
6060
"minimum_credits": 6,
6161
"unique_course_limit": 3,
62+
"maximum_days": 4,
63+
"mandatory_days": ["MON", "WED"],
6264
"times": {
6365
"MON": ["09:00-17:00"],
6466
"TUE": ["09:00-17:00"],
@@ -218,13 +220,17 @@ The configuration system uses several type aliases for validation:
218220
- **`maximum_credits`**: Maximum credit hours they can teach (required, non-negative integer)
219221
- **`minimum_credits`**: Minimum credit hours they must teach (required, non-negative integer)
220222
- **`unique_course_limit`**: Maximum number of different courses they can teach (required, positive integer)
223+
- **`maximum_days`**: Maximum number of distinct days they will teach (optional, defaults to 5)
224+
- **`mandatory_days`**: Set of days they must teach on (optional, defaults to empty)
221225
- **`times`**: Available time slots by day (required, non-empty dict)
222226
- **`course_preferences`**: Course preference scores (0-10, higher = more preferred, optional)
223227
- **`room_preferences`**: Room preference scores (0-10, higher = more preferred, optional)
224228
- **`lab_preferences`**: Lab preference scores (0-10, higher = more preferred, optional)
225229

226230
**Validation Rules:**
227231
- `minimum_credits` cannot be greater than `maximum_credits`
232+
- `mandatory_days` must be a subset of the days listed in `times`
233+
- `maximum_days` must be greater than or equal to the number of `mandatory_days`
228234
- All course IDs in preferences must exist in the courses list
229235
- All room names in preferences must exist in the rooms list
230236
- All lab names in preferences must exist in the labs list
@@ -244,6 +250,8 @@ The configuration system uses several type aliases for validation:
244250

245251
**Best Practices:**
246252
- Set realistic credit limits based on faculty workload
253+
- Use `maximum_days` to cap teaching days when faculty want compressed schedules
254+
- Reserve `mandatory_days` for commitments like standing department meetings or required course coverage
247255
- Use preference scores to guide optimization
248256
- Ensure availability times are accurate and comprehensive
249257
- Consider faculty expertise when assigning course preferences

example.json

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"maximum_credits": 12,
2828
"minimum_credits": 12,
2929
"unique_course_limit": 3,
30+
"maximum_days": 4,
3031
"times": {
3132
"MON": ["11:00-16:00"],
3233
"TUE": [],
@@ -54,11 +55,13 @@
5455
"maximum_credits": 14,
5556
"minimum_credits": 12,
5657
"unique_course_limit": 2,
58+
"maximum_days": 4,
59+
"mandatory_days": ["MON", "WED", "FRI"],
5760
"times": {
5861
"MON": ["09:00-15:00"],
5962
"TUE": ["09:00-15:00"],
6063
"WED": ["09:00-15:00"],
61-
"THU": [],
64+
"THU": ["09:00-15:00"],
6265
"FRI": ["09:00-15:00"]
6366
},
6467
"course_preferences": {
@@ -80,9 +83,11 @@
8083
"maximum_credits": 8,
8184
"minimum_credits": 8,
8285
"unique_course_limit": 2,
86+
"maximum_days": 4,
87+
"mandatory_days": ["MON", "WED"],
8388
"times": {
8489
"MON": ["09:00-13:00"],
85-
"TUE": [],
90+
"TUE": ["09:00-15:00"],
8691
"WED": ["09:00-15:00"],
8792
"THU": ["09:00-15:00"],
8893
"FRI": ["09:00-15:00"]
@@ -106,11 +111,13 @@
106111
"maximum_credits": 12,
107112
"minimum_credits": 12,
108113
"unique_course_limit": 2,
114+
"maximum_days": 4,
115+
"mandatory_days": ["WED"],
109116
"times": {
110117
"MON": ["09:00-15:00"],
111118
"TUE": ["09:00-15:00"],
112119
"WED": ["09:00-15:00"],
113-
"THU": [],
120+
"THU": ["09:00-15:00"],
114121
"FRI": ["09:00-15:00"]
115122
},
116123
"course_preferences": {
@@ -207,11 +214,13 @@
207214
"maximum_credits": 12,
208215
"minimum_credits": 12,
209216
"unique_course_limit": 2,
217+
"maximum_days": 4,
218+
"mandatory_days": ["WED"],
210219
"times": {
211220
"MON": ["09:00-17:00"],
212221
"TUE": ["09:00-17:00"],
213222
"WED": ["09:00-15:00"],
214-
"THU": [],
223+
"THU": ["09:00-17:00"],
215224
"FRI": ["09:00-17:00"]
216225
},
217226
"course_preferences": {

src/scheduler/config.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,17 @@ class FacultyConfig(StrictBaseModel):
443443
Maximum credit hours they can teach
444444
"""
445445

446+
maximum_days: int = Field(
447+
default=5,
448+
ge=0,
449+
le=5,
450+
description="Maximum number of days they are willing to teach (0-5, optional)",
451+
json_schema_extra={"example": 3},
452+
)
453+
"""
454+
Maximum number of days they are willing to teach (optional)
455+
"""
456+
446457
minimum_credits: int = Field(
447458
description="Minimum credit hours they must teach", ge=0, json_schema_extra={"example": 3}
448459
)
@@ -493,6 +504,15 @@ class FacultyConfig(StrictBaseModel):
493504
Dictionary mapping `Lab` IDs to `Preference` scores
494505
"""
495506

507+
mandatory_days: set[Day] = Field(
508+
default_factory=set,
509+
description="Set of days the faculty must teach on",
510+
json_schema_extra={"example": ["MON", "WED"]},
511+
)
512+
"""
513+
Set of days the faculty must teach on
514+
"""
515+
496516
@field_validator("times", mode="before")
497517
@classmethod
498518
def _convert_time_strings(cls, v):
@@ -511,6 +531,13 @@ def _convert_time_strings(cls, v):
511531
return converted
512532
return v
513533

534+
@field_validator("mandatory_days", mode="before")
535+
@classmethod
536+
def _convert_mandatory_days(cls, v):
537+
if isinstance(v, list | tuple):
538+
return set(v)
539+
return v
540+
514541
@model_validator(mode="after")
515542
def validate(self):
516543
"""
@@ -521,6 +548,18 @@ def validate(self):
521548
f"Minimum credits ({self.minimum_credits}) cannot be greater than "
522549
f"maximum credits ({self.maximum_credits})"
523550
)
551+
if self.maximum_days < len(self.mandatory_days):
552+
raise ValueError(
553+
f"maximum_days ({self.maximum_days}) cannot be less than the number of mandatory days "
554+
f"({len(self.mandatory_days)})"
555+
)
556+
available_days = {day if isinstance(day, str) else str(day) for day in self.times}
557+
mandatory_days = {day if isinstance(day, str) else str(day) for day in self.mandatory_days}
558+
unavailable_mandatory = mandatory_days - available_days
559+
if unavailable_mandatory:
560+
raise ValueError(
561+
f"Mandatory days {sorted(unavailable_mandatory)} must be present in the availability times"
562+
)
524563
return self
525564

526565

@@ -565,9 +604,11 @@ class SchedulerConfig(StrictBaseModel):
565604
{
566605
"name": "Dr. Smith",
567606
"maximum_credits": 12,
607+
"maximum_days": 3,
568608
"minimum_credits": 3,
569609
"unique_course_limit": 3,
570610
"times": {"MON": ["10:00-12:00"], "TUE": ["10:00-12:00"]},
611+
"mandatory_days": ["MON"],
571612
"course_preferences": {"CS 101": 5},
572613
"room_preferences": {"Room 101": 5},
573614
"lab_preferences": {"Lab 101": 5},

src/scheduler/scheduler.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,15 @@ def _initialize_faculty_data(self, config) -> None:
123123
faculty_name = faculty_data.name
124124
self._faculty.add(faculty_name)
125125
self._faculty_maximum_credits[faculty_name] = faculty_data.maximum_credits
126+
self._faculty_maximum_days[faculty_name] = faculty_data.maximum_days
126127
self._faculty_minimum_credits[faculty_name] = faculty_data.minimum_credits
127128
self._faculty_unique_course_limits[faculty_name] = faculty_data.unique_course_limit
128129
self._faculty_course_preferences[faculty_name] = faculty_data.course_preferences
129130
self._faculty_room_preferences[faculty_name] = faculty_data.room_preferences
130131
self._faculty_lab_preferences[faculty_name] = faculty_data.lab_preferences
132+
self._faculty_mandatory_days[faculty_name] = {
133+
day if isinstance(day, Day) else Day[day] for day in faculty_data.mandatory_days
134+
}
131135
self._faculty_availability[faculty_name] = get_faculty_availability(faculty_data)
132136

133137
def _initialize_courses(self, config) -> tuple[list[Course], set[int]]:
@@ -246,11 +250,13 @@ def __init__(self, full_config: CombinedConfig):
246250
# Initialize data structures
247251
self._faculty: set[str] = set()
248252
self._faculty_maximum_credits: dict[str, int] = dict()
253+
self._faculty_maximum_days: dict[str, int] = dict()
249254
self._faculty_minimum_credits: dict[str, int] = dict()
250255
self._faculty_unique_course_limits: dict[str, int] = dict()
251256
self._faculty_course_preferences: dict[str, dict[str, int]] = dict()
252257
self._faculty_room_preferences: dict[str, dict[str, int]] = dict()
253258
self._faculty_lab_preferences: dict[str, dict[str, int]] = dict()
259+
self._faculty_mandatory_days: dict[str, set[Day]] = dict()
254260
self._faculty_availability: dict[str, list[TimeInstance]] = dict()
255261
self._initialize_faculty_data(config)
256262

@@ -326,8 +332,8 @@ def _z3ify_time_constraint(
326332
false: list[tuple[z3.BoolRef, z3.BoolRef]] = []
327333

328334
for slot_i, slot_j in itertools.combinations_with_replacement(self._slots, 2):
329-
c_i = z3_data.time_slot_constants[slot_i]
330-
c_j = z3_data.time_slot_constants[slot_j]
335+
c_i = cast(z3.BoolRef, z3_data.time_slot_constants[slot_i])
336+
c_j = cast(z3.BoolRef, z3_data.time_slot_constants[slot_j])
331337
if self._cached_slot_relationship(name, slot_i, slot_j):
332338
true.append((c_i, c_j))
333339
true.append((c_j, c_i))
@@ -361,7 +367,7 @@ def _z3ify_time_slot_fn(
361367
true: list[z3.BoolRef] = []
362368
false: list[z3.BoolRef] = []
363369
for slot in self._slots:
364-
c = z3_data.time_slot_constants[slot]
370+
c = cast(z3.BoolRef, z3_data.time_slot_constants[slot])
365371
if fn(slot):
366372
true.append(c)
367373
else:
@@ -388,9 +394,9 @@ def _z3ify_faculty_time_constraint(
388394
true: list[tuple[z3.BoolRef, z3.BoolRef]] = []
389395
false: list[tuple[z3.BoolRef, z3.BoolRef]] = []
390396
faculty_times = self._faculty_availability[faculty]
391-
faculty_constant = z3_data.faculty_constants[faculty]
397+
faculty_constant = cast(z3.BoolRef, z3_data.faculty_constants[faculty])
392398
for slot in self._slots:
393-
slot_constant = z3_data.time_slot_constants[slot]
399+
slot_constant = cast(z3.BoolRef, z3_data.time_slot_constants[slot])
394400
if slot.in_time_ranges(faculty_times):
395401
true.append((faculty_constant, slot_constant))
396402
else:
@@ -463,11 +469,23 @@ def _build_faculty_constraints(self, z3_data: _Z3SortsAndConstants) -> list[z3.B
463469
for faculty in c.faculties:
464470
faculty_course_map[faculty].append(c)
465471

472+
# Pre-compute time slot constants per day for reuse
473+
day_slot_map: defaultdict[Day, set[z3.ExprRef]] = defaultdict(set)
474+
for slot in self._slots:
475+
slot_constant = z3_data.time_slot_constants[slot]
476+
for time_instance in slot.times:
477+
day_slot_map[time_instance.day].add(slot_constant)
478+
day_to_slot_constants: dict[Day, tuple[z3.ExprRef, ...]] = {
479+
day: tuple(slot_constants) for day, slot_constants in day_slot_map.items()
480+
}
481+
466482
# Add faculty credit and unique course limits - batch generation
467483
faculty_constraints: list[z3.BoolRef] = []
468484
for faculty in self._faculty:
469485
faculty_courses = faculty_course_map.get(faculty, [])
470486
faculty_constant = z3_data.faculty_constants[faculty]
487+
max_days = self._faculty_maximum_days[faculty]
488+
mandatory_days = self._faculty_mandatory_days[faculty]
471489
if faculty_courses:
472490
min_credits = self._faculty_minimum_credits[faculty]
473491
max_credits = self._faculty_maximum_credits[faculty]
@@ -505,6 +523,54 @@ def _build_faculty_constraints(self, z3_data: _Z3SortsAndConstants) -> list[z3.B
505523
)
506524
faculty_constraints.append(limit)
507525

526+
# Track whether the faculty teaches on a given day
527+
day_indicator_map: dict[Day, z3.BoolRef] = {}
528+
for day in Day:
529+
slot_constants = day_to_slot_constants.get(day, ())
530+
course_day_assignments: list[z3.BoolRef] = []
531+
if slot_constants and faculty_courses:
532+
for course in faculty_courses:
533+
slot_matches = [course.time == slot_const for slot_const in slot_constants]
534+
if slot_matches:
535+
course_day_assignments.append(
536+
cast(
537+
z3.BoolRef,
538+
self._simplify(
539+
z3.And(
540+
course.faculty == faculty_constant,
541+
z3.Or(slot_matches),
542+
)
543+
),
544+
)
545+
)
546+
if course_day_assignments:
547+
day_indicator_map[day] = cast(
548+
z3.BoolRef,
549+
self._simplify(z3.Or(course_day_assignments)),
550+
)
551+
else:
552+
day_indicator_map[day] = z3.BoolVal(False, ctx=self._ctx)
553+
554+
# Maximum-day constraint
555+
day_sum_terms = [z3.If(indicator, 1, 0) for indicator in day_indicator_map.values()]
556+
day_sum = z3.Sum(day_sum_terms) if day_sum_terms else z3.IntVal(0, ctx=self._ctx)
557+
faculty_constraints.append(
558+
cast(
559+
z3.BoolRef,
560+
self._simplify(day_sum <= max_days),
561+
)
562+
)
563+
564+
# Mandatory-day constraints
565+
for mandatory_day in mandatory_days:
566+
indicator = day_indicator_map.get(mandatory_day, z3.BoolVal(False, ctx=self._ctx))
567+
faculty_constraints.append(
568+
cast(
569+
z3.BoolRef,
570+
self._simplify(indicator),
571+
)
572+
)
573+
508574
return faculty_constraints
509575

510576
def _build_course_constraints(
@@ -659,6 +725,10 @@ def _build_resource_constraints(
659725
diff_course_constraints.append(z3.Not(lab_next_to(i.time, j.time)))
660726
constraint_parts.append(cast(z3.BoolRef, z3.And(diff_course_constraints)))
661727

728+
if i.course_id == j.course_id:
729+
# prevent overlapping times for different sections of the same course
730+
resource_constraints.append(cast(z3.BoolRef, z3.Not(overlaps(i.time, j.time))))
731+
662732
if resource:
663733
# add all resource constraints (room, lab, etc.)
664734
resource_constraints.append(cast(z3.BoolRef, z3.And(resource)))

0 commit comments

Comments
 (0)