musawar32ali's picture
Create app.py
9909198 verified
# 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)