musawar32ali commited on
Commit
9909198
·
verified ·
1 Parent(s): f1fc9a7

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +433 -0
app.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ import csv
3
+ import io
4
+ import os
5
+ import random
6
+ from typing import List, Dict, Tuple
7
+
8
+ import gradio as gr
9
+ import numpy as np
10
+ import pandas as pd
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ # ------------------------
16
+ # Genetic Algorithm Logic
17
+ # ------------------------
18
+
19
+ class TimetableGA:
20
+ def __init__(
21
+ self,
22
+ courses: List[str],
23
+ teachers: List[str],
24
+ rooms: List[str],
25
+ days: List[str],
26
+ slots: List[str],
27
+ teacher_unavailable: Dict[str, List[Tuple[str, str]]], # teacher -> list of (day,slot)
28
+ course_teacher_pref: Dict[str, List[str]], # course -> possible teachers
29
+ room_constraints: Dict[str, List[str]], # course -> allowed rooms (optional)
30
+ population_size: int = 100,
31
+ generations: int = 200,
32
+ mutation_rate: float = 0.05,
33
+ ):
34
+ self.courses = courses
35
+ self.teachers = teachers
36
+ self.rooms = rooms
37
+ self.days = days
38
+ self.slots = slots
39
+ self.teacher_unavailable = teacher_unavailable
40
+ self.course_teacher_pref = course_teacher_pref
41
+ self.room_constraints = room_constraints or {}
42
+ self.population_size = population_size
43
+ self.generations = generations
44
+ self.mutation_rate = mutation_rate
45
+
46
+ self.times = [(d, s) for d in days for s in slots]
47
+ self.num_periods = len(self.times)
48
+ self.num_courses = len(courses)
49
+
50
+ def _random_individual(self):
51
+ """
52
+ Individual representation:
53
+ - For each course, a tuple (period_index, room_index, teacher_index)
54
+ - Represented as arrays: period_indices, room_indices, teacher_indices
55
+ """
56
+ period_indices = np.random.randint(0, self.num_periods, size=self.num_courses)
57
+ room_indices = np.random.randint(0, len(self.rooms), size=self.num_courses)
58
+ teacher_indices = np.zeros(self.num_courses, dtype=int)
59
+ for i, c in enumerate(self.courses):
60
+ # choose teacher from preferences if provided, else random
61
+ prefs = self.course_teacher_pref.get(c, None)
62
+ if prefs:
63
+ # pick one of allowed teachers
64
+ teacher_indices[i] = self.teachers.index(random.choice(prefs))
65
+ else:
66
+ teacher_indices[i] = np.random.randint(0, len(self.teachers))
67
+ return (period_indices, room_indices, teacher_indices)
68
+
69
+ def _fitness(self, individual):
70
+ period_indices, room_indices, teacher_indices = individual
71
+ score = 0
72
+ penalties = 0
73
+
74
+ # 1) Teacher conflicts: a teacher cannot teach more than one course in same period
75
+ teacher_period = {}
76
+ for i, t_idx in enumerate(teacher_indices):
77
+ key = (t_idx, int(period_indices[i]))
78
+ teacher_period.setdefault(key, 0)
79
+ teacher_period[key] += 1
80
+ teacher_conflicts = sum(max(0, cnt - 1) for cnt in teacher_period.values())
81
+ penalties += teacher_conflicts * 5
82
+
83
+ # 2) Room conflicts
84
+ room_period = {}
85
+ for i, r_idx in enumerate(room_indices):
86
+ key = (r_idx, int(period_indices[i]))
87
+ room_period.setdefault(key, 0)
88
+ room_period[key] += 1
89
+ room_conflicts = sum(max(0, cnt - 1) for cnt in room_period.values())
90
+ penalties += room_conflicts * 5
91
+
92
+ # 3) Teacher availability violations
93
+ avail_violations = 0
94
+ for i, t_idx in enumerate(teacher_indices):
95
+ teacher = self.teachers[t_idx]
96
+ period = self.times[int(period_indices[i])]
97
+ if teacher in self.teacher_unavailable:
98
+ if period in self.teacher_unavailable[teacher]:
99
+ avail_violations += 1
100
+ penalties += avail_violations * 10
101
+
102
+ # 4) Course-teacher preference violations
103
+ pref_violations = 0
104
+ for i, c in enumerate(self.courses):
105
+ prefs = self.course_teacher_pref.get(c)
106
+ if prefs:
107
+ chosen_teacher = self.teachers[teacher_indices[i]]
108
+ if chosen_teacher not in prefs:
109
+ pref_violations += 1
110
+ penalties += pref_violations * 2
111
+
112
+ # 5) Room constraint violations
113
+ room_violations = 0
114
+ for i, c in enumerate(self.courses):
115
+ allowed = self.room_constraints.get(c)
116
+ if allowed:
117
+ chosen_room = self.rooms[room_indices[i]]
118
+ if chosen_room not in allowed:
119
+ room_violations += 1
120
+ penalties += room_violations * 4
121
+
122
+ # 6) Spread penalty (optional): same course multiple occurrences in same day slot collisions (if course repeated)
123
+ # For simple use-case assume each course appears once - no extra penalty.
124
+
125
+ # Fitness: higher is better. Start from base and subtract penalties.
126
+ base = 1000
127
+ fitness = base - penalties
128
+ # Provide components for debugging in return as well
129
+ return fitness
130
+
131
+ def _crossover(self, parent_a, parent_b):
132
+ # single-point crossover on all arrays
133
+ cut = np.random.randint(1, self.num_courses - 1)
134
+ a_period, a_room, a_teacher = parent_a
135
+ b_period, b_room, b_teacher = parent_b
136
+ child1 = (
137
+ np.concatenate([a_period[:cut], b_period[cut:]]),
138
+ np.concatenate([a_room[:cut], b_room[cut:]]),
139
+ np.concatenate([a_teacher[:cut], b_teacher[cut:]]),
140
+ )
141
+ child2 = (
142
+ np.concatenate([b_period[:cut], a_period[cut:]]),
143
+ np.concatenate([b_room[:cut], a_room[cut:]]),
144
+ np.concatenate([b_teacher[:cut], a_teacher[cut:]]),
145
+ )
146
+ return child1, child2
147
+
148
+ def _mutate(self, individual):
149
+ period_indices, room_indices, teacher_indices = individual
150
+ for i in range(self.num_courses):
151
+ if random.random() < self.mutation_rate:
152
+ period_indices[i] = random.randint(0, self.num_periods - 1)
153
+ if random.random() < self.mutation_rate:
154
+ room_indices[i] = random.randint(0, len(self.rooms) - 1)
155
+ if random.random() < self.mutation_rate:
156
+ prefs = self.course_teacher_pref.get(self.courses[i], None)
157
+ if prefs:
158
+ teacher_indices[i] = self.teachers.index(random.choice(prefs))
159
+ else:
160
+ teacher_indices[i] = random.randint(0, len(self.teachers) - 1)
161
+ return (period_indices, room_indices, teacher_indices)
162
+
163
+ def run(self, verbose=False):
164
+ # Initialize population
165
+ population = [self._random_individual() for _ in range(self.population_size)]
166
+ fitnesses = [self._fitness(ind) for ind in population]
167
+ best = population[np.argmax(fitnesses)]
168
+ best_score = max(fitnesses)
169
+
170
+ for gen in range(self.generations):
171
+ # Selection (tournament)
172
+ new_pop = []
173
+ while len(new_pop) < self.population_size:
174
+ i1, i2 = random.sample(range(self.population_size), 2)
175
+ p1 = population[i1] if fitnesses[i1] > fitnesses[i2] else population[i2]
176
+ i3, i4 = random.sample(range(self.population_size), 2)
177
+ p2 = population[i3] if fitnesses[i3] > fitnesses[i4] else population[i4]
178
+
179
+ c1, c2 = self._crossover(p1, p2)
180
+ c1 = self._mutate(c1)
181
+ c2 = self._mutate(c2)
182
+ new_pop.extend([c1, c2])
183
+
184
+ population = new_pop[: self.population_size]
185
+ fitnesses = [self._fitness(ind) for ind in population]
186
+
187
+ gen_best_idx = int(np.argmax(fitnesses))
188
+ gen_best_score = fitnesses[gen_best_idx]
189
+ if gen_best_score > best_score:
190
+ best_score = gen_best_score
191
+ best = population[gen_best_idx]
192
+
193
+ # early exit if perfect
194
+ if best_score >= 1000:
195
+ break
196
+
197
+ if verbose and gen % max(1, self.generations // 10) == 0:
198
+ print(f"Gen {gen} best {best_score}")
199
+
200
+ return {"best": best, "score": best_score, "times": self.times}
201
+
202
+ # ------------------------
203
+ # Helpers to convert individual -> dataframe
204
+ # ------------------------
205
+
206
+ def individual_to_dataframe(individual, courses, teachers, rooms, times):
207
+ period_indices, room_indices, teacher_indices = individual
208
+ rows = []
209
+ for i, course in enumerate(courses):
210
+ period_idx = int(period_indices[i])
211
+ day, slot = times[period_idx]
212
+ rows.append(
213
+ {
214
+ "Course": course,
215
+ "Teacher": teachers[int(teacher_indices[i])],
216
+ "Room": rooms[int(room_indices[i])],
217
+ "Day": day,
218
+ "Slot": slot,
219
+ }
220
+ )
221
+ return pd.DataFrame(rows).sort_values(["Day", "Slot"]).reset_index(drop=True)
222
+
223
+ def dataframe_to_csv_bytes(df: pd.DataFrame):
224
+ buf = io.StringIO()
225
+ df.to_csv(buf, index=False)
226
+ return buf.getvalue().encode("utf-8")
227
+
228
+ # ------------------------
229
+ # Gradio UI
230
+ # ------------------------
231
+
232
+ def parse_multiline_list(text: str) -> List[str]:
233
+ return [line.strip() for line in text.splitlines() if line.strip()]
234
+
235
+ def parse_teacher_unavailability(text: str) -> Dict[str, List[Tuple[str,str]]]:
236
+ # Format per line: Teacher,Day,Slot
237
+ # Example: T1_Ali,Monday,Slot1
238
+ d = {}
239
+ for ln in text.splitlines():
240
+ ln = ln.strip()
241
+ if not ln:
242
+ continue
243
+ parts = [p.strip() for p in ln.split(",")]
244
+ if len(parts) >= 3:
245
+ teacher, day, slot = parts[0], parts[1], parts[2]
246
+ d.setdefault(teacher, []).append((day, slot))
247
+ return d
248
+
249
+ def parse_course_teacher_pref(text: str) -> Dict[str, List[str]]:
250
+ # Format per line: Course: T1,T2
251
+ d = {}
252
+ for ln in text.splitlines():
253
+ ln = ln.strip()
254
+ if not ln:
255
+ continue
256
+ if ":" in ln:
257
+ course, rest = ln.split(":", 1)
258
+ teachers = [t.strip() for t in rest.split(",") if t.strip()]
259
+ d[course.strip()] = teachers
260
+ return d
261
+
262
+ def parse_room_constraints(text: str) -> Dict[str, List[str]]:
263
+ # Format per line: Course: R1,R2
264
+ d = {}
265
+ for ln in text.splitlines():
266
+ ln = ln.strip()
267
+ if not ln:
268
+ continue
269
+ if ":" in ln:
270
+ course, rest = ln.split(":", 1)
271
+ rooms = [r.strip() for r in rest.split(",") if r.strip()]
272
+ d[course.strip()] = rooms
273
+ return d
274
+
275
+ def run_ga_and_return_csv(
276
+ courses_text, teachers_text, rooms_text, days_text, slots_text,
277
+ teacher_unavail_text, course_teacher_pref_text, room_constraints_text,
278
+ pop_size, generations, mutation_rate, seed=42
279
+ ):
280
+ random.seed(int(seed))
281
+ np.random.seed(int(seed))
282
+
283
+ courses = parse_multiline_list(courses_text)
284
+ teachers = parse_multiline_list(teachers_text)
285
+ rooms = parse_multiline_list(rooms_text)
286
+ days = parse_multiline_list(days_text)
287
+ slots = parse_multiline_list(slots_text)
288
+ if not (courses and teachers and rooms and days and slots):
289
+ return "Please provide at least one course, teacher, room, day, and slot.", None, None
290
+
291
+ teacher_unavail = parse_teacher_unavailability(teacher_unavail_text)
292
+ course_teacher_pref = parse_course_teacher_pref(course_teacher_pref_text)
293
+ room_constraints = parse_room_constraints(room_constraints_text)
294
+
295
+ ga = TimetableGA(
296
+ courses=courses,
297
+ teachers=teachers,
298
+ rooms=rooms,
299
+ days=days,
300
+ slots=slots,
301
+ teacher_unavailable=teacher_unavail,
302
+ course_teacher_pref=course_teacher_pref,
303
+ room_constraints=room_constraints,
304
+ population_size=int(pop_size),
305
+ generations=int(generations),
306
+ mutation_rate=float(mutation_rate),
307
+ )
308
+ result = ga.run(verbose=False)
309
+ best = result["best"]
310
+ score = result["score"]
311
+ times = result["times"]
312
+ df = individual_to_dataframe(best, courses, teachers, rooms, times)
313
+ csv_bytes = dataframe_to_csv_bytes(df)
314
+ summary = f"GA finished. Best fitness score: {score}. Generated timetable rows: {len(df)}"
315
+ return summary, df, csv_bytes
316
+
317
+ # ------------------------
318
+ # Build Gradio app
319
+ # ------------------------
320
+
321
+ with gr.Blocks(title="Automatic Time Table Generation Agent (Genetic Algorithm)") as demo:
322
+ gr.Markdown("# Automatic Time Table Generation Agent (Genetic Algorithm)")
323
+ gr.Markdown(
324
+ "Create an optimized timetable using a genetic algorithm. "
325
+ "Enter courses, teachers, rooms, days and slots; optionally provide teacher unavailability, course-teacher preferences and room constraints."
326
+ )
327
+
328
+ with gr.Row():
329
+ with gr.Column(scale=1):
330
+ gr.Markdown("## Inputs")
331
+ courses_in = gr.Textbox(label="Courses (one per line)", value="C1_Math\nC2_Physics\nC3_Chemistry\nC4_English\nC5_Biology\nC6_History", lines=6)
332
+ teachers_in = gr.Textbox(label="Teachers (one per line)", value="T1_Ali\nT2_Sara\nT3_Omar\nT4_Fatima", lines=4)
333
+ rooms_in = gr.Textbox(label="Rooms (one per line)", value="R1\nR2\nR3\nR4\nR5", lines=4)
334
+ days_in = gr.Textbox(label="Days (one per line)", value="Monday\nTuesday\nWednesday\nThursday\nFriday", lines=5)
335
+ slots_in = gr.Textbox(label="Slots (one per line)", value="Slot1\nSlot2\nSlot3\nSlot4\nSlot5\nSlot6", lines=6)
336
+
337
+ gr.Markdown("### Optional constraints")
338
+ teacher_unavail_in = gr.Textbox(label="Teacher unavailability (one per line: Teacher,Day,Slot)", value="", lines=4)
339
+ course_teacher_pref_in = gr.Textbox(label="Course -> allowed teachers (one per line: Course: T1,T2)", value="", lines=4)
340
+ room_constraints_in = gr.Textbox(label="Course -> allowed rooms (one per line: Course: R1,R2)", value="", lines=4)
341
+
342
+ gr.Markdown("### GA parameters")
343
+ pop_in = gr.Slider(label="Population size", minimum=10, maximum=500, value=120, step=10)
344
+ gen_in = gr.Slider(label="Generations", minimum=10, maximum=2000, value=300, step=10)
345
+ mut_in = gr.Slider(label="Mutation rate", minimum=0.0, maximum=0.5, value=0.05, step=0.01)
346
+ seed_in = gr.Number(label="Random seed", value=42)
347
+
348
+ run_btn = gr.Button("Run Genetic Algorithm")
349
+
350
+ with gr.Column(scale=1):
351
+ gr.Markdown("## Output")
352
+ summary_out = gr.Textbox(label="Summary", lines=3)
353
+ table_out = gr.Dataframe(headers=["Course","Teacher","Room","Day","Slot"], interactive=False)
354
+ download_btn = gr.File(label="Download CSV")
355
+ gr.Markdown("## Timetable Agent (Chat)")
356
+ chat_in = gr.Textbox(label="Ask the Timetable Agent (explain conflicts, suggest fixes...)")
357
+ chat_out = gr.Textbox(label="Agent response", lines=6)
358
+
359
+ def run_and_prepare_download(*args):
360
+ (
361
+ courses_text, teachers_text, rooms_text, days_text, slots_text,
362
+ teacher_unavail_text, course_teacher_pref_text, room_constraints_text,
363
+ pop_size, generations, mutation_rate, seed
364
+ ) = args
365
+ summary, df, csv_bytes = run_ga_and_return_csv(
366
+ courses_text, teachers_text, rooms_text, days_text, slots_text,
367
+ teacher_unavail_text, course_teacher_pref_text, room_constraints_text,
368
+ pop_size, generations, mutation_rate, seed
369
+ )
370
+ if df is None:
371
+ return summary, None, None, None
372
+ # prepare in-memory file for Gradio
373
+ file_obj = io.BytesIO(csv_bytes)
374
+ file_obj.name = "timetable.csv"
375
+ return summary, df, file_obj, "Timetable generated. Ask the agent for an explanation."
376
+
377
+ run_btn.click(
378
+ run_and_prepare_download,
379
+ inputs=[
380
+ courses_in, teachers_in, rooms_in, days_in, slots_in,
381
+ teacher_unavail_in, course_teacher_pref_in, room_constraints_in,
382
+ pop_in, gen_in, mut_in, seed_in
383
+ ],
384
+ outputs=[summary_out, table_out, download_btn, chat_out],
385
+ show_progress=True,
386
+ )
387
+
388
+ # Simple local "chat" behavior (not LLM)
389
+ def simple_agent(query, summary_text, df):
390
+ if not query:
391
+ return "Type a question like: 'Which teachers have conflicts?' or 'Suggest 3 ways to reduce conflicts.'"
392
+ if df is None or df.shape[0] == 0:
393
+ return "No timetable available. Run the GA first."
394
+ # Basic analysis
395
+ text = query.lower()
396
+ response_lines = []
397
+ if "conflict" in text or "conflicts" in text or "problem" in text:
398
+ # detect teacher conflicts
399
+ tconf = df.groupby(["Teacher","Day","Slot"]).size().reset_index(name="count")
400
+ tconf = tconf[tconf["count"]>1]
401
+ if not tconf.empty:
402
+ response_lines.append("Teacher conflicts found:")
403
+ for _, row in tconf.iterrows():
404
+ response_lines.append(f"- {row['Teacher']} has {row['count']} assignments on {row['Day']} {row['Slot']}")
405
+ else:
406
+ response_lines.append("No teacher conflicts detected.")
407
+
408
+ # room conflicts
409
+ rconf = df.groupby(["Room","Day","Slot"]).size().reset_index(name="count")
410
+ rconf = rconf[rconf["count"]>1]
411
+ if not rconf.empty:
412
+ response_lines.append("Room conflicts found:")
413
+ for _, row in rconf.iterrows():
414
+ response_lines.append(f"- {row['Room']} has {row['count']} assignments on {row['Day']} {row['Slot']}")
415
+ else:
416
+ response_lines.append("No room conflicts detected.")
417
+ response_lines.append("Suggested fixes: 1) add rooms or change room constraints, 2) change teacher availability for offending periods, 3) allow alternate teachers for affected courses.")
418
+ return "\n".join(response_lines)
419
+
420
+ if "suggest" in text or "improve" in text or "reduce" in text:
421
+ return "Three quick suggestions:\n1) Increase number of rooms or relax room constraints.\n2) Allow more teachers per course (course-teacher preferences).\n3) Move a class to a different slot/day for teachers with conflicts."
422
+
423
+ # default explanation: show top 5 assignments
424
+ sample = df.head(8).to_string(index=False)
425
+ return f"Timetable summary (first rows):\n{sample}\n\nAsk for 'conflicts' or 'suggestions' for improvement."
426
+
427
+ chat_btn = gr.Button("Ask Agent")
428
+ chat_btn.click(simple_agent, inputs=[chat_in, summary_out, table_out], outputs=[chat_out])
429
+
430
+ gr.Markdown("### Notes\n- This genetic algorithm is a demonstrative solver. For production use you should add stronger constraints (room capacities, repeating lessons, student group clashes) and tune GA parameters.")
431
+
432
+ if __name__ == "__main__":
433
+ demo.launch(server_name="0.0.0.0", share=False)