File size: 18,653 Bytes
9909198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# app.py
import csv
import io
import os
import random
from typing import List, Dict, Tuple

import gradio as gr
import numpy as np
import pandas as pd
from dotenv import load_dotenv

load_dotenv()

# ------------------------
# Genetic Algorithm Logic
# ------------------------

class TimetableGA:
    def __init__(
        self,
        courses: List[str],
        teachers: List[str],
        rooms: List[str],
        days: List[str],
        slots: List[str],
        teacher_unavailable: Dict[str, List[Tuple[str, str]]],  # teacher -> list of (day,slot)
        course_teacher_pref: Dict[str, List[str]],  # course -> possible teachers
        room_constraints: Dict[str, List[str]],  # course -> allowed rooms (optional)
        population_size: int = 100,
        generations: int = 200,
        mutation_rate: float = 0.05,
    ):
        self.courses = courses
        self.teachers = teachers
        self.rooms = rooms
        self.days = days
        self.slots = slots
        self.teacher_unavailable = teacher_unavailable
        self.course_teacher_pref = course_teacher_pref
        self.room_constraints = room_constraints or {}
        self.population_size = population_size
        self.generations = generations
        self.mutation_rate = mutation_rate

        self.times = [(d, s) for d in days for s in slots]
        self.num_periods = len(self.times)
        self.num_courses = len(courses)

    def _random_individual(self):
        """
        Individual representation:
         - For each course, a tuple (period_index, room_index, teacher_index)
         - Represented as arrays: period_indices, room_indices, teacher_indices
        """
        period_indices = np.random.randint(0, self.num_periods, size=self.num_courses)
        room_indices = np.random.randint(0, len(self.rooms), size=self.num_courses)
        teacher_indices = np.zeros(self.num_courses, dtype=int)
        for i, c in enumerate(self.courses):
            # choose teacher from preferences if provided, else random
            prefs = self.course_teacher_pref.get(c, None)
            if prefs:
                # pick one of allowed teachers
                teacher_indices[i] = self.teachers.index(random.choice(prefs))
            else:
                teacher_indices[i] = np.random.randint(0, len(self.teachers))
        return (period_indices, room_indices, teacher_indices)

    def _fitness(self, individual):
        period_indices, room_indices, teacher_indices = individual
        score = 0
        penalties = 0

        # 1) Teacher conflicts: a teacher cannot teach more than one course in same period
        teacher_period = {}
        for i, t_idx in enumerate(teacher_indices):
            key = (t_idx, int(period_indices[i]))
            teacher_period.setdefault(key, 0)
            teacher_period[key] += 1
        teacher_conflicts = sum(max(0, cnt - 1) for cnt in teacher_period.values())
        penalties += teacher_conflicts * 5

        # 2) Room conflicts
        room_period = {}
        for i, r_idx in enumerate(room_indices):
            key = (r_idx, int(period_indices[i]))
            room_period.setdefault(key, 0)
            room_period[key] += 1
        room_conflicts = sum(max(0, cnt - 1) for cnt in room_period.values())
        penalties += room_conflicts * 5

        # 3) Teacher availability violations
        avail_violations = 0
        for i, t_idx in enumerate(teacher_indices):
            teacher = self.teachers[t_idx]
            period = self.times[int(period_indices[i])]
            if teacher in self.teacher_unavailable:
                if period in self.teacher_unavailable[teacher]:
                    avail_violations += 1
        penalties += avail_violations * 10

        # 4) Course-teacher preference violations
        pref_violations = 0
        for i, c in enumerate(self.courses):
            prefs = self.course_teacher_pref.get(c)
            if prefs:
                chosen_teacher = self.teachers[teacher_indices[i]]
                if chosen_teacher not in prefs:
                    pref_violations += 1
        penalties += pref_violations * 2

        # 5) Room constraint violations
        room_violations = 0
        for i, c in enumerate(self.courses):
            allowed = self.room_constraints.get(c)
            if allowed:
                chosen_room = self.rooms[room_indices[i]]
                if chosen_room not in allowed:
                    room_violations += 1
        penalties += room_violations * 4

        # 6) Spread penalty (optional): same course multiple occurrences in same day slot collisions (if course repeated)
        # For simple use-case assume each course appears once - no extra penalty.

        # Fitness: higher is better. Start from base and subtract penalties.
        base = 1000
        fitness = base - penalties
        # Provide components for debugging in return as well
        return fitness

    def _crossover(self, parent_a, parent_b):
        # single-point crossover on all arrays
        cut = np.random.randint(1, self.num_courses - 1)
        a_period, a_room, a_teacher = parent_a
        b_period, b_room, b_teacher = parent_b
        child1 = (
            np.concatenate([a_period[:cut], b_period[cut:]]),
            np.concatenate([a_room[:cut], b_room[cut:]]),
            np.concatenate([a_teacher[:cut], b_teacher[cut:]]),
        )
        child2 = (
            np.concatenate([b_period[:cut], a_period[cut:]]),
            np.concatenate([b_room[:cut], a_room[cut:]]),
            np.concatenate([b_teacher[:cut], a_teacher[cut:]]),
        )
        return child1, child2

    def _mutate(self, individual):
        period_indices, room_indices, teacher_indices = individual
        for i in range(self.num_courses):
            if random.random() < self.mutation_rate:
                period_indices[i] = random.randint(0, self.num_periods - 1)
            if random.random() < self.mutation_rate:
                room_indices[i] = random.randint(0, len(self.rooms) - 1)
            if random.random() < self.mutation_rate:
                prefs = self.course_teacher_pref.get(self.courses[i], None)
                if prefs:
                    teacher_indices[i] = self.teachers.index(random.choice(prefs))
                else:
                    teacher_indices[i] = random.randint(0, len(self.teachers) - 1)
        return (period_indices, room_indices, teacher_indices)

    def run(self, verbose=False):
        # Initialize population
        population = [self._random_individual() for _ in range(self.population_size)]
        fitnesses = [self._fitness(ind) for ind in population]
        best = population[np.argmax(fitnesses)]
        best_score = max(fitnesses)

        for gen in range(self.generations):
            # Selection (tournament)
            new_pop = []
            while len(new_pop) < self.population_size:
                i1, i2 = random.sample(range(self.population_size), 2)
                p1 = population[i1] if fitnesses[i1] > fitnesses[i2] else population[i2]
                i3, i4 = random.sample(range(self.population_size), 2)
                p2 = population[i3] if fitnesses[i3] > fitnesses[i4] else population[i4]

                c1, c2 = self._crossover(p1, p2)
                c1 = self._mutate(c1)
                c2 = self._mutate(c2)
                new_pop.extend([c1, c2])

            population = new_pop[: self.population_size]
            fitnesses = [self._fitness(ind) for ind in population]

            gen_best_idx = int(np.argmax(fitnesses))
            gen_best_score = fitnesses[gen_best_idx]
            if gen_best_score > best_score:
                best_score = gen_best_score
                best = population[gen_best_idx]

            # early exit if perfect
            if best_score >= 1000:
                break

            if verbose and gen % max(1, self.generations // 10) == 0:
                print(f"Gen {gen} best {best_score}")

        return {"best": best, "score": best_score, "times": self.times}

# ------------------------
# Helpers to convert individual -> dataframe
# ------------------------

def individual_to_dataframe(individual, courses, teachers, rooms, times):
    period_indices, room_indices, teacher_indices = individual
    rows = []
    for i, course in enumerate(courses):
        period_idx = int(period_indices[i])
        day, slot = times[period_idx]
        rows.append(
            {
                "Course": course,
                "Teacher": teachers[int(teacher_indices[i])],
                "Room": rooms[int(room_indices[i])],
                "Day": day,
                "Slot": slot,
            }
        )
    return pd.DataFrame(rows).sort_values(["Day", "Slot"]).reset_index(drop=True)

def dataframe_to_csv_bytes(df: pd.DataFrame):
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return buf.getvalue().encode("utf-8")

# ------------------------
# Gradio UI
# ------------------------

def parse_multiline_list(text: str) -> List[str]:
    return [line.strip() for line in text.splitlines() if line.strip()]

def parse_teacher_unavailability(text: str) -> Dict[str, List[Tuple[str,str]]]:
    # Format per line: Teacher,Day,Slot
    # Example: T1_Ali,Monday,Slot1
    d = {}
    for ln in text.splitlines():
        ln = ln.strip()
        if not ln: 
            continue
        parts = [p.strip() for p in ln.split(",")]
        if len(parts) >= 3:
            teacher, day, slot = parts[0], parts[1], parts[2]
            d.setdefault(teacher, []).append((day, slot))
    return d

def parse_course_teacher_pref(text: str) -> Dict[str, List[str]]:
    # Format per line: Course: T1,T2
    d = {}
    for ln in text.splitlines():
        ln = ln.strip()
        if not ln: 
            continue
        if ":" in ln:
            course, rest = ln.split(":", 1)
            teachers = [t.strip() for t in rest.split(",") if t.strip()]
            d[course.strip()] = teachers
    return d

def parse_room_constraints(text: str) -> Dict[str, List[str]]:
    # Format per line: Course: R1,R2
    d = {}
    for ln in text.splitlines():
        ln = ln.strip()
        if not ln:
            continue
        if ":" in ln:
            course, rest = ln.split(":", 1)
            rooms = [r.strip() for r in rest.split(",") if r.strip()]
            d[course.strip()] = rooms
    return d

def run_ga_and_return_csv(
    courses_text, teachers_text, rooms_text, days_text, slots_text,
    teacher_unavail_text, course_teacher_pref_text, room_constraints_text,
    pop_size, generations, mutation_rate, seed=42
):
    random.seed(int(seed))
    np.random.seed(int(seed))

    courses = parse_multiline_list(courses_text)
    teachers = parse_multiline_list(teachers_text)
    rooms = parse_multiline_list(rooms_text)
    days = parse_multiline_list(days_text)
    slots = parse_multiline_list(slots_text)
    if not (courses and teachers and rooms and days and slots):
        return "Please provide at least one course, teacher, room, day, and slot.", None, None

    teacher_unavail = parse_teacher_unavailability(teacher_unavail_text)
    course_teacher_pref = parse_course_teacher_pref(course_teacher_pref_text)
    room_constraints = parse_room_constraints(room_constraints_text)

    ga = TimetableGA(
        courses=courses,
        teachers=teachers,
        rooms=rooms,
        days=days,
        slots=slots,
        teacher_unavailable=teacher_unavail,
        course_teacher_pref=course_teacher_pref,
        room_constraints=room_constraints,
        population_size=int(pop_size),
        generations=int(generations),
        mutation_rate=float(mutation_rate),
    )
    result = ga.run(verbose=False)
    best = result["best"]
    score = result["score"]
    times = result["times"]
    df = individual_to_dataframe(best, courses, teachers, rooms, times)
    csv_bytes = dataframe_to_csv_bytes(df)
    summary = f"GA finished. Best fitness score: {score}. Generated timetable rows: {len(df)}"
    return summary, df, csv_bytes

# ------------------------
# Build Gradio app
# ------------------------

with gr.Blocks(title="Automatic Time Table Generation Agent (Genetic Algorithm)") as demo:
    gr.Markdown("# Automatic Time Table Generation Agent (Genetic Algorithm)")
    gr.Markdown(
        "Create an optimized timetable using a genetic algorithm. "
        "Enter courses, teachers, rooms, days and slots; optionally provide teacher unavailability, course-teacher preferences and room constraints."
    )

    with gr.Row():
        with gr.Column(scale=1):
            gr.Markdown("## Inputs")
            courses_in = gr.Textbox(label="Courses (one per line)", value="C1_Math\nC2_Physics\nC3_Chemistry\nC4_English\nC5_Biology\nC6_History", lines=6)
            teachers_in = gr.Textbox(label="Teachers (one per line)", value="T1_Ali\nT2_Sara\nT3_Omar\nT4_Fatima", lines=4)
            rooms_in = gr.Textbox(label="Rooms (one per line)", value="R1\nR2\nR3\nR4\nR5", lines=4)
            days_in = gr.Textbox(label="Days (one per line)", value="Monday\nTuesday\nWednesday\nThursday\nFriday", lines=5)
            slots_in = gr.Textbox(label="Slots (one per line)", value="Slot1\nSlot2\nSlot3\nSlot4\nSlot5\nSlot6", lines=6)

            gr.Markdown("### Optional constraints")
            teacher_unavail_in = gr.Textbox(label="Teacher unavailability (one per line: Teacher,Day,Slot)", value="", lines=4)
            course_teacher_pref_in = gr.Textbox(label="Course -> allowed teachers (one per line: Course: T1,T2)", value="", lines=4)
            room_constraints_in = gr.Textbox(label="Course -> allowed rooms (one per line: Course: R1,R2)", value="", lines=4)

            gr.Markdown("### GA parameters")
            pop_in = gr.Slider(label="Population size", minimum=10, maximum=500, value=120, step=10)
            gen_in = gr.Slider(label="Generations", minimum=10, maximum=2000, value=300, step=10)
            mut_in = gr.Slider(label="Mutation rate", minimum=0.0, maximum=0.5, value=0.05, step=0.01)
            seed_in = gr.Number(label="Random seed", value=42)

            run_btn = gr.Button("Run Genetic Algorithm")

        with gr.Column(scale=1):
            gr.Markdown("## Output")
            summary_out = gr.Textbox(label="Summary", lines=3)
            table_out = gr.Dataframe(headers=["Course","Teacher","Room","Day","Slot"], interactive=False)
            download_btn = gr.File(label="Download CSV")
            gr.Markdown("## Timetable Agent (Chat)")
            chat_in = gr.Textbox(label="Ask the Timetable Agent (explain conflicts, suggest fixes...)")
            chat_out = gr.Textbox(label="Agent response", lines=6)

    def run_and_prepare_download(*args):
        (
            courses_text, teachers_text, rooms_text, days_text, slots_text,
            teacher_unavail_text, course_teacher_pref_text, room_constraints_text,
            pop_size, generations, mutation_rate, seed
        ) = args
        summary, df, csv_bytes = run_ga_and_return_csv(
            courses_text, teachers_text, rooms_text, days_text, slots_text,
            teacher_unavail_text, course_teacher_pref_text, room_constraints_text,
            pop_size, generations, mutation_rate, seed
        )
        if df is None:
            return summary, None, None, None
        # prepare in-memory file for Gradio
        file_obj = io.BytesIO(csv_bytes)
        file_obj.name = "timetable.csv"
        return summary, df, file_obj, "Timetable generated. Ask the agent for an explanation."

    run_btn.click(
        run_and_prepare_download,
        inputs=[
            courses_in, teachers_in, rooms_in, days_in, slots_in,
            teacher_unavail_in, course_teacher_pref_in, room_constraints_in,
            pop_in, gen_in, mut_in, seed_in
        ],
        outputs=[summary_out, table_out, download_btn, chat_out],
        show_progress=True,
    )

    # Simple local "chat" behavior (not LLM)
    def simple_agent(query, summary_text, df):
        if not query:
            return "Type a question like: 'Which teachers have conflicts?' or 'Suggest 3 ways to reduce conflicts.'"
        if df is None or df.shape[0] == 0:
            return "No timetable available. Run the GA first."
        # Basic analysis
        text = query.lower()
        response_lines = []
        if "conflict" in text or "conflicts" in text or "problem" in text:
            # detect teacher conflicts
            tconf = df.groupby(["Teacher","Day","Slot"]).size().reset_index(name="count")
            tconf = tconf[tconf["count"]>1]
            if not tconf.empty:
                response_lines.append("Teacher conflicts found:")
                for _, row in tconf.iterrows():
                    response_lines.append(f"- {row['Teacher']} has {row['count']} assignments on {row['Day']} {row['Slot']}")
            else:
                response_lines.append("No teacher conflicts detected.")

            # room conflicts
            rconf = df.groupby(["Room","Day","Slot"]).size().reset_index(name="count")
            rconf = rconf[rconf["count"]>1]
            if not rconf.empty:
                response_lines.append("Room conflicts found:")
                for _, row in rconf.iterrows():
                    response_lines.append(f"- {row['Room']} has {row['count']} assignments on {row['Day']} {row['Slot']}")
            else:
                response_lines.append("No room conflicts detected.")
            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.")
            return "\n".join(response_lines)

        if "suggest" in text or "improve" in text or "reduce" in text:
            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."

        # default explanation: show top 5 assignments
        sample = df.head(8).to_string(index=False)
        return f"Timetable summary (first rows):\n{sample}\n\nAsk for 'conflicts' or 'suggestions' for improvement."

    chat_btn = gr.Button("Ask Agent")
    chat_btn.click(simple_agent, inputs=[chat_in, summary_out, table_out], outputs=[chat_out])

    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.")

if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", share=False)