-
Notifications
You must be signed in to change notification settings - Fork 1
/
scheduler.py
319 lines (259 loc) · 12.9 KB
/
scheduler.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
import json
import itertools
import sys
from typing import Any, Callable, Dict, Iterable, List, Tuple
import json_fix
import z3
from course import Course
from lab import Lab
from room import Room
from time_slot import Day, Duration, TimeInstance, TimePoint, TimeSlot
from config import time_slots
def load_from_file(filename):
with open(filename) as f:
return json.load(f)
class Scheduler:
def __init__(self, json_data : Dict[str, Any]):
LOOKUP: List[str] = ["credits", "subj", "num", "lab", "room", "faculty", "conflicts"]
self.rooms: Dict[str, Room] = dict(
(r, Room(r)) for r in json_data['rooms']
)
self.labs: Dict[str, Lab] = dict(
(l, Lab(l)) for l in json_data['labs']
)
courses_sorted_by_faculty = list(sorted(json_data['courses'], key=lambda x:x["faculty"]))
self.courses: List[Course] = [Course(*(c[v] for v in LOOKUP)) for c in courses_sorted_by_faculty]
Time = Tuple[int, int]
Range = Tuple[Time, Time]
DayRanges = List[Range]
def get_info(person: Dict[str, List[DayRanges]]) -> List[TimeInstance]:
days: List[Day] = [Day.MON, Day.TUE, Day.WED, Day.THU, Day.FRI]
result : List[TimeInstance] = list()
for day, times in zip(days, person['times']):
for start, end in times:
start_time: TimePoint = TimePoint.make_from(*start)
end_time: TimePoint = TimePoint.make_from(*end)
duration: Duration = end_time - start_time
result.append(TimeInstance(day, start_time, duration))
return result
self.faculty: dict[str, List[TimeInstance]] = {
faculty: get_info(times) for faculty, times in json_data['faculty'].items()
}
def generate_slots() -> Tuple[Dict[int, Tuple[int, int]], List[TimeSlot]]:
ranges: Dict[int, Tuple[int, int]] = dict()
slots: List[TimeSlot] = list()
low = TimeSlot.min_id()
for credits in [3, 4]:
for s in time_slots(credits):
slots.append(s)
high = TimeSlot.max_id()
ranges[credits] = (low, high)
low = TimeSlot.max_id() + 1
return ranges, slots
ranges, slots = generate_slots()
self.ranges: Dict[int, Tuple[int, int]] = ranges
self.slots: List[TimeSlot] = slots
hard, soft = self._build()
self.constraints = hard
self.soft_constraints = soft
@staticmethod
def _simplify(x):
return z3.simplify(x, cache_all=True, rewrite_patterns=True)
def _z3ify_time_constraint(self, name: str, fn: Callable[[TimeSlot,TimeSlot], bool]) -> Tuple[z3.Function, List[z3.ArithRef]]:
z3fn = z3.Function(name, z3.IntSort(), z3.IntSort(), z3.BoolSort())
def generate():
for i, j in itertools.combinations(self.slots, 2):
ii, jj = z3.IntVal(i.id), z3.IntVal(j.id)
if fn(i, j):
yield z3fn(ii, jj)
yield z3fn(jj, ii)
else:
yield z3.Not(z3fn(ii, jj))
yield z3.Not(z3fn(jj, ii))
for i in self.slots:
ii = z3.IntVal(i.id)
yield z3fn(ii, ii)
return z3fn, [Scheduler._simplify(x) for x in generate()]
def _z3ify_time_slot_fn(self, name: str, fn: Callable[[TimeSlot], bool]) -> Tuple[z3.Function, List[z3.ArithRef]]:
z3fn = z3.Function(name, z3.IntSort(), z3.BoolSort())
def generate():
for i in self.slots:
ii = z3.IntVal(i.id)
if fn(i):
yield z3fn(ii)
else:
yield z3.Not(z3fn(ii))
return z3fn, [Scheduler._simplify(x) for x in generate()]
def _build(self):
# abstract function constraints
overlaps, overlaps_C = self._z3ify_time_constraint(
'overlaps', TimeSlot.overlaps)
labs_on_same_day, labs_on_same_day_C = self._z3ify_time_constraint(
'labs_on_same_day', TimeSlot.labs_on_same_day)
lab_overlaps, lab_overlaps_C = self._z3ify_time_constraint(
'lab_overlaps', TimeSlot.lab_overlaps)
next_to, next_to_C = self._z3ify_time_constraint(
'next_to', TimeSlot.next_to)
lab_starts_with_lecture, lab_starts_with_lecture_C = self._z3ify_time_slot_fn(
'lab_starts_with_lecture', TimeSlot.lab_starts_with_lecture)
has_lab, has_lab_C = self._z3ify_time_slot_fn(
'has_lab', TimeSlot.has_lab)
not_next_to, not_next_to_C = self._z3ify_time_constraint(
'not_next_to', TimeSlot.not_next_to)
next_to_tues_wed, next_to_tues_wed_C = self._z3ify_time_constraint(
'next_to_tues_wed', TimeSlot.next_to_tues_wed)
fn_constraints = overlaps_C + lab_overlaps_C + next_to_C + \
labs_on_same_day_C + has_lab_C + not_next_to_C
soft_fn_constraints = next_to_tues_wed_C + lab_starts_with_lecture_C
def hard_constraints():
for c in self.courses:
start, stop = self.ranges[c.credits]
yield z3.And([
# basic timeslot constraint
z3.And([start <= c.time(), c.time() <= stop]),
# we must assign to a lab when we have options
z3.Implies(len(c.labs) > 0, z3.Or([c.lab() == lab.id for name, lab in self.labs.items() if name in c.labs])),
# we must assign to a lab when we have options
z3.Implies(len(c.rooms) > 0, z3.Or([c.room() == room.id for name, room in self.rooms.items() if name in c.rooms])),
# check the other courses time slot constraint(s)
z3.And([z3.Not(overlaps(c.time(), d.time())) for conflict_num in c.conflicts for d in self.courses if d.num == conflict_num]),
# check the faculty time constraint
z3.Or([c.time() == slot.id for slot in self.slots if slot.in_time_ranges(self.faculty[c.faculty()])])
])
for _, courses in itertools.groupby(self.courses, Course.faculty):
courses = list(c for c in courses)
for i, j in itertools.combinations(courses, 2):
yield z3.And([
# check for unique, non-overlapping timeslots for each faculty
z3.Not(overlaps(i.time(), j.time())),
# ensure sections of the same class are adjacent and that sections of different classes are NOT adjacent
z3.simplify(z3.If(
(i.subject == j.subject and i.num == j.num),
z3.And([
next_to(i.time(), j.time()), i.room() == j.room(),
z3.Implies(z3.And(has_lab(i.time()), has_lab(j.time())), i.lab() == j.lab())
]),
not_next_to(i.time(), j.time())
))
])
for i, j, k in itertools.combinations((c for c in courses if c.labs), 3):
# add constraint that all three two-hour period must be on different days
yield z3.Or(
z3.Not(labs_on_same_day(i.time(), j.time())),
z3.Not(labs_on_same_day(j.time(), k.time())),
z3.Not(labs_on_same_day(i.time(), k.time()))
)
for i, j in itertools.combinations(self.courses, 2):
yield z3.And([
# any two courses must not have a resource overlap
z3.Implies(i.room() == j.room(), z3.Not(overlaps(i.time(), j.time()))),
# any two courses with a lab must not have a resource overlap
z3.simplify(z3.Implies(
z3.And(
len(i.labs) > 0 and len(j.labs) > 0,
i.lab() == j.lab()
),
z3.Not(lab_overlaps(i.time(), j.time()))
))
])
def soft_constraints():
for _, courses in itertools.groupby(self.courses, Course.faculty):
for _, v in itertools.groupby(courses, Course.uid):
# useful for fall schedules -- ensures no T/W split between different sections for labs
yield z3.And([z3.Not(next_to_tues_wed(i.time(), j.time())) for i, j in itertools.combinations(v, 2)])
for _, courses in itertools.groupby(self.courses, Course.faculty):
for _, v in itertools.groupby(courses, Course.uid):
v = list(x for x in v)
if len(v) == 1:
# if we only have a single course, make sure the lab start time aligns with lecture start time
yield lab_starts_with_lecture(v[0].time());
C = [Scheduler._simplify(x) for x in hard_constraints()]
S = [Scheduler._simplify(x) for x in soft_constraints()]
hard = fn_constraints + C
soft = soft_fn_constraints + S
return hard, soft
def get_models(self, limit=10):
def update(i: int, s: z3.Solver) -> Iterable[Tuple[int, z3.ModelRef, z3.Statistics]]:
m : z3.ModelRef = s.model()
stat : z3.Statistics = s.statistics()
yield i, m, stat
def constraints():
for _, courses in itertools.groupby(self.courses, Course.faculty):
for _, v in itertools.groupby(list(courses), Course.uid):
ordered = list(v)
if ordered[0].labs:
yield z3.Or([
z3.And([z3.And(act_time == exp.time(), act_room == exp.room(), act_lab == exp.lab())
for (act_time, act_room, act_lab), exp in zip(((m[c.time()], m[c.room()], m[c.lab()]) for c in ordered), r)
])
for r in itertools.permutations(ordered)
])
else:
yield z3.Or([
z3.And([z3.And(act_time == exp.time(), act_room == exp.room())
for (act_time, act_room), exp in zip(((m[c.time()], m[c.room()]) for c in ordered), r)
])
for r in itertools.permutations(ordered)
])
s.add(Scheduler._simplify(z3.Not(z3.And(*constraints()))))
s = z3.Solver()
s.add(self.constraints)
s.push()
s.add(self.soft_constraints)
if s.check() != z3.sat:
print("WARNING: Soft constraints are non-sat... relaxing constraints", file=sys.stderr)
s.pop()
for i in range(limit):
if s.check() != z3.sat:
if i == 0:
print("WARNING: no possible schedule", file=sys.stderr)
else:
print(f"Generated {i} schedules", file=sys.stderr)
break
yield from update(i, s)
def concretize(map: Dict):
def iter():
for k, v in map.items():
if k == 'room':
yield (k, Room.get(map["room"]).name)
elif k == 'lab':
if v:
yield (k, {'room': Lab.get(map["lab"]).name, 'time': TimeSlot.get(map["time"]).lab_time()})
elif k == 'time':
yield (k, list(t for t in TimeSlot.get(map["time"])._times))
else:
yield (k, v)
return dict(iter())
def generate_models(data, limit):
s = Scheduler(load_from_file(data))
def all():
for _, m, _ in s.get_models(limit):
yield list(concretize(c.evaluate(m)) for c in s.courses)
return json.dumps(list(all()), separators=(',', ':'))
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <json_config> [limit=10] [json]")
exit(1)
config_file = sys.argv[1]
limit = 10 if len(sys.argv) == 2 else int(sys.argv[2])
dump_json = len(sys.argv) == 4 and sys.argv[3] == "json"
if dump_json:
print(generate_models(config_file, limit))
exit(0)
print(f"> Using limit={limit}")
sched = Scheduler(load_from_file(config_file))
print(f"> Created all constraints")
for i, m, s in sched.get_models(limit):
print(f'Model {i}:')
print(' ', end='')
for j in s.keys():
print(f'{j}:{s.get_key_value(j)} ', end='')
print('\n')
print('\n'.join(c.csv(m) for c in sched.courses))
try:
print()
input('press <enter> for the next schedule (or Ctrl+D) to quit')
print()
print("> Getting next model...")
except:
exit(0)