Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import pandas as pd | |
| import zipfile | |
| import os | |
| from collections import defaultdict | |
| from openpyxl import Workbook | |
| from openpyxl.styles import ( | |
| Font, | |
| Alignment, | |
| Border, | |
| Side | |
| ) | |
| from openpyxl.utils import get_column_letter | |
| # ========================================================= | |
| # SUBJECT GROUPS | |
| # ========================================================= | |
| SCIENCE_SUBJECTS = { | |
| 'Physics', 'Chemistry', 'Mathematics', | |
| 'Biology', 'Statistics', 'Computer Science', | |
| 'Bengali', 'English', 'I.C.' | |
| } | |
| ARTS_SUBJECTS = { | |
| 'History', 'Geography', 'Education', | |
| 'Political Science', 'Pol. Sc.', | |
| 'Philosophy', 'Sanskrit', | |
| 'Computer Application', | |
| 'Bengali', 'English', 'I.C.' | |
| } | |
| COMMON_XI_XII = {'Bengali', 'English', 'I.C.'} | |
| # ========================================================= | |
| # SUBJECT ABBREVIATIONS | |
| # ========================================================= | |
| SUBJECT_ABBR = { | |
| 'Bengali': 'Beng', | |
| 'English': 'Eng', | |
| 'History': 'Hist', | |
| 'Geography': 'Geo', | |
| 'Mathematics': 'Math', | |
| 'Life Science': 'L. Sc.', | |
| 'Sanskrit': 'Sans', | |
| 'Physical Science': 'P. Sc.', | |
| 'Computer Science': 'COMS', | |
| 'Physics': 'Phy', | |
| 'Chemistry': 'Chem', | |
| 'Statistics': 'Stat', | |
| 'Computer': 'Comp', | |
| 'Biology': 'Bio', | |
| 'Political Science': 'Pol Sc.', | |
| 'Pol. Sc.': 'Pol Sc.', | |
| 'Philosophy': 'Phil', | |
| 'Computer Application': 'COMA', | |
| 'Hindi': 'Hindi', | |
| 'I.C.': 'I.C.', | |
| 'Education': 'Edu' | |
| } | |
| # ========================================================= | |
| # SIMULTANEOUS GROUPS | |
| # ========================================================= | |
| SIMULTANEOUS_GROUPS = [ | |
| {'Biology', 'Computer Science', 'Statistics'}, | |
| {'Philosophy', 'Geography'}, | |
| {'Education', 'Sanskrit'}, | |
| {'Political Science', 'Pol. Sc.', 'Computer Application'} | |
| ] | |
| # ========================================================= | |
| # SCHOOL CONFIG | |
| # ========================================================= | |
| WORKING_DAYS = [ | |
| 'Monday', | |
| 'Tuesday', | |
| 'Wednesday', | |
| 'Thursday', | |
| 'Friday', | |
| 'Saturday' | |
| ] | |
| PERIODS_WEEKDAYS = [ | |
| 'Period 1', | |
| 'Period 2', | |
| 'Period 3', | |
| 'Period 4', | |
| 'Period 5', | |
| 'Period 6', | |
| 'Period 7' | |
| ] | |
| PERIODS_SATURDAY = [ | |
| 'Period 1', | |
| 'Period 2', | |
| 'Period 3', | |
| 'Period 4' | |
| ] | |
| XI_NOPERIOD_6_7_DAYS = ['Monday', 'Wednesday'] | |
| XII_NOPERIOD_6_7_DAYS = ['Monday', 'Tuesday'] | |
| V_TO_VII_PRIORITY = ['VII', 'VI', 'V'] | |
| ROMAN_CLASS_MAP = { | |
| 'V': 5, | |
| 'VI': 6, | |
| 'VII': 7, | |
| 'VIII': 8, | |
| 'IX': 9, | |
| 'X': 10, | |
| 'XI': 11, | |
| 'XII': 12 | |
| } | |
| # ========================================================= | |
| # MAIN CLASS | |
| # ========================================================= | |
| class TimetableGenerator: | |
| def __init__(self): | |
| self.timetable = defaultdict(lambda: defaultdict(dict)) | |
| self.teacher_weekly_load = defaultdict(int) | |
| self.unassigned = [] | |
| # ===================================================== | |
| # LOAD FILES | |
| # ===================================================== | |
| def load_files(self, teacher_file, class_dist_file, section_file): | |
| self.teachers_df = pd.read_excel(teacher_file) | |
| self.class_dist_df = pd.read_excel(class_dist_file) | |
| self.section_df = pd.read_excel(section_file) | |
| self.class_dist_df['Class'] = ( | |
| self.class_dist_df['Class'] | |
| .astype(str) | |
| .str.strip() | |
| .str.upper() | |
| ) | |
| self.section_df['Class'] = ( | |
| self.section_df['Class'] | |
| .astype(str) | |
| .str.strip() | |
| .str.upper() | |
| ) | |
| self.all_teachers = ( | |
| self.teachers_df['Teachers Name'] | |
| .dropna() | |
| .unique() | |
| .tolist() | |
| ) | |
| for t in self.all_teachers: | |
| self.teacher_weekly_load[t] = 0 | |
| # ===================================================== | |
| # CLASS NUMBER | |
| # ===================================================== | |
| def class_to_num(self, cls): | |
| return ROMAN_CLASS_MAP.get( | |
| str(cls).strip().upper(), | |
| 0 | |
| ) | |
| # ===================================================== | |
| # ELIGIBLE TEACHERS | |
| # ===================================================== | |
| def get_eligible_teachers(self, subject, cls=None): | |
| eligibles = [] | |
| cls_num = self.class_to_num(cls) if cls else 0 | |
| # ===================================================== | |
| # SPECIAL RULE FOR PHYSICAL SCIENCE | |
| # ===================================================== | |
| if subject == 'Physical Science': | |
| if 8 <= cls_num <= 10: | |
| physics_teachers = [] | |
| chemistry_teachers = [] | |
| for _, row in self.teachers_df.iterrows(): | |
| teacher = row['Teachers Name'] | |
| main_sub = str(row.get('Main Subject', '')).strip() | |
| if main_sub == 'Physics': | |
| if teacher not in physics_teachers: physics_teachers.append(teacher) | |
| elif main_sub == 'Chemistry': | |
| if teacher not in chemistry_teachers: chemistry_teachers.append(teacher) | |
| eligibles.extend(physics_teachers) | |
| for t in chemistry_teachers: | |
| if t not in eligibles: eligibles.append(t) | |
| elif 5 <= cls_num <= 7: | |
| for _, row in self.teachers_df.iterrows(): | |
| teacher = row['Teachers Name'] | |
| for col in ['Main Subject', 'Second Subject', 'Third Subject']: | |
| sub = str(row.get(col, '')).strip() | |
| if sub in ['Physics', 'Chemistry']: | |
| if teacher not in eligibles: eligibles.append(teacher) | |
| return eligibles | |
| for col in ['Main Subject', 'Second Subject', 'Third Subject']: | |
| if col in self.teachers_df.columns: | |
| matches = self.teachers_df[self.teachers_df[col] == subject]['Teachers Name'].tolist() | |
| for t in matches: | |
| if t not in eligibles: eligibles.append(t) | |
| return eligibles | |
| # =============================== | |
| # Backfilling / Repair Scheduling | |
| # ============================== | |
| def repair_unassigned_classes( | |
| self, | |
| teacher_occupied, | |
| teacher_daily_load, | |
| class_daily_subjects, | |
| max_period_lookup | |
| ): | |
| remaining_unassigned = [] | |
| for item in self.unassigned: | |
| cs = item['Class-Section'] | |
| sub = item['Subject'] | |
| remaining = item['Unassigned Periods'] | |
| cls = cs.split('-')[0] | |
| eligible_teachers = self.get_eligible_teachers(sub, cls) | |
| assigned_count = 0 | |
| for day in WORKING_DAYS: | |
| if assigned_count >= remaining: break | |
| periods = PERIODS_SATURDAY if day == 'Saturday' else PERIODS_WEEKDAYS | |
| if cls == 'XI' and 'A' in cs and day in XI_NOPERIOD_6_7_DAYS: | |
| periods = periods[:5] | |
| if cls == 'XII' and 'A' in cs and day in XII_NOPERIOD_6_7_DAYS: | |
| periods = periods[:5] | |
| for p in periods: | |
| if assigned_count >= remaining: break | |
| if self.timetable[cs][day].get(p): continue | |
| for teacher in eligible_teachers: | |
| if p in teacher_occupied[day][teacher]: continue | |
| max_p = max_period_lookup.get(teacher, 0) | |
| if teacher_daily_load[teacher][day] >= max_p: continue | |
| display_sub = SUBJECT_ABBR.get(sub, sub) | |
| self.timetable[cs][day][p] = {'subject': f"{display_sub} - {teacher}",'teacher': teacher,'raw_sub': sub} | |
| teacher_occupied[day][teacher].add(p) | |
| teacher_daily_load[teacher][day] += 1 | |
| self.teacher_weekly_load[teacher] += 1 | |
| assigned_count += 1 | |
| break | |
| still_left = remaining - assigned_count | |
| if still_left > 0: | |
| item['Unassigned Periods'] = still_left | |
| remaining_unassigned.append(item) | |
| self.unassigned = remaining_unassigned | |
| # ===================================================== | |
| # SIMULTANEOUS CHECK | |
| # ===================================================== | |
| def can_overlap(self, sub1, sub2): | |
| for group in SIMULTANEOUS_GROUPS: | |
| if sub1 in group and sub2 in group: | |
| return True | |
| return False | |
| # ===================================================== | |
| # UNASSIGNED REPORT | |
| # ===================================================== | |
| def add_unassigned( | |
| self, | |
| cls_sec, | |
| subject, | |
| remaining, | |
| reason | |
| ): | |
| if remaining > 0: | |
| self.unassigned.append({ | |
| "Class-Section": cls_sec, | |
| "Subject": subject, | |
| "Unassigned Periods": remaining, | |
| "Reason": reason | |
| }) | |
| def get_failure_reason( | |
| self, | |
| eligible_teachers, | |
| teacher_occupied, | |
| teacher_daily_load, | |
| periods_by_day, | |
| max_period_lookup | |
| ): | |
| for teacher in eligible_teachers: | |
| max_p = max_period_lookup.get(teacher, 0) | |
| for day, periods in periods_by_day.items(): | |
| if teacher_daily_load[teacher][day] < max_p: | |
| for p in periods: | |
| if p not in teacher_occupied[day][teacher]: | |
| return "No free slot" | |
| return "Teacher unavailable" | |
| # ===================================================== | |
| # EXCEL FORMATTING | |
| # ===================================================== | |
| def format_excel(self, ws): | |
| thin = Side( | |
| border_style="thin", | |
| color="000000" | |
| ) | |
| border = Border( | |
| left=thin, | |
| right=thin, | |
| top=thin, | |
| bottom=thin | |
| ) | |
| for row in ws.iter_rows(): | |
| for cell in row: | |
| cell.alignment = Alignment( | |
| horizontal='center', | |
| vertical='center', | |
| wrap_text=True | |
| ) | |
| cell.border = border | |
| for col in ws.columns: | |
| max_len = 0 | |
| col_letter = get_column_letter( | |
| col[0].column | |
| ) | |
| for cell in col: | |
| try: | |
| val = str(cell.value) | |
| if len(val) > max_len: | |
| max_len = len(val) | |
| except: | |
| pass | |
| ws.column_dimensions[ | |
| col_letter | |
| ].width = max_len + 4 | |
| # ========================================================= | |
| # FULL GENERATE ROUTINE FUNCTION | |
| # ========================================================= | |
| def generate_routine(self): | |
| self.timetable = defaultdict(lambda: defaultdict(dict)) | |
| self.unassigned = [] | |
| # Reset weekly load every time routine is generated | |
| self.teacher_weekly_load = defaultdict(int) | |
| for teacher in self.all_teachers: | |
| self.teacher_weekly_load[teacher] = 0 | |
| teacher_daily_load = defaultdict(lambda: defaultdict(int)) | |
| teacher_occupied = defaultdict(lambda: defaultdict(set)) | |
| class_daily_subjects = defaultdict(lambda: defaultdict(set)) | |
| class_req = defaultdict(dict) | |
| max_period_lookup = {} | |
| # ===================================================== | |
| # TEACHER DAILY MAX PERIOD LOOKUP | |
| # ===================================================== | |
| for _, row in self.teachers_df.iterrows(): | |
| max_period_lookup[row['Teachers Name']] = row['Max Periods Per Day'] | |
| # ===================================================== | |
| # CLASS SUBJECT REQUIREMENTS | |
| # ===================================================== | |
| for _, r in self.class_dist_df.iterrows(): | |
| cls = str(r['Class']).strip().upper() | |
| sub = str(r['Subject']).strip() | |
| ppw = int(r['Periods per Week']) | |
| class_req[cls][sub] = ppw | |
| sorted_classes = sorted( | |
| class_req.keys(), | |
| key=lambda x: ( | |
| x not in V_TO_VII_PRIORITY, | |
| self.class_to_num(x) | |
| ) | |
| ) | |
| # ===================================================== | |
| # STEP 1: PRIORITY COMMON XI/XII SUBJECTS | |
| # Bengali, English, I.C. common between Science and Arts | |
| # ===================================================== | |
| for priority_cls in ['XI', 'XII']: | |
| if priority_cls not in class_req: | |
| continue | |
| sections = self.section_df[ | |
| self.section_df['Class'] == priority_cls | |
| ]['Section'].tolist() | |
| sci_sec = next( | |
| ( | |
| s for s in sections | |
| if str(s).upper() in ['A', 'SCIENCE'] | |
| ), | |
| None | |
| ) | |
| art_sec = next( | |
| ( | |
| s for s in sections | |
| if str(s).upper() in ['B', 'ARTS'] | |
| ), | |
| None | |
| ) | |
| sci_cs = f"{priority_cls}-{sci_sec}" if sci_sec else None | |
| art_cs = f"{priority_cls}-{art_sec}" if art_sec else None | |
| for sub in ['Bengali', 'English', 'I.C.']: | |
| if sub not in class_req[priority_cls]: | |
| continue | |
| eligible = self.get_eligible_teachers( | |
| sub, | |
| priority_cls | |
| ) | |
| if not eligible: | |
| if sci_cs: | |
| self.add_unassigned( | |
| sci_cs, | |
| sub, | |
| class_req[priority_cls][sub], | |
| "Teacher unavailable" | |
| ) | |
| if art_cs: | |
| self.add_unassigned( | |
| art_cs, | |
| sub, | |
| class_req[priority_cls][sub], | |
| "Teacher unavailable" | |
| ) | |
| continue | |
| remaining = class_req[priority_cls][sub] | |
| for day in WORKING_DAYS: | |
| if remaining <= 0: | |
| break | |
| periods = ( | |
| PERIODS_SATURDAY | |
| if day == 'Saturday' | |
| else PERIODS_WEEKDAYS | |
| ) | |
| if priority_cls == 'XI' and day in XI_NOPERIOD_6_7_DAYS: | |
| periods = periods[:5] | |
| if priority_cls == 'XII' and day in XII_NOPERIOD_6_7_DAYS: | |
| periods = periods[:5] | |
| for p in periods: | |
| if remaining <= 0: | |
| break | |
| if ( | |
| sci_cs and p in self.timetable[sci_cs][day] | |
| ) or ( | |
| art_cs and p in self.timetable[art_cs][day] | |
| ): | |
| continue | |
| assigned = False | |
| for t in eligible: | |
| if ( | |
| p not in teacher_occupied[day][t] | |
| and teacher_daily_load[t][day] < max_period_lookup.get(t, 0) | |
| ): | |
| disp = SUBJECT_ABBR.get( | |
| sub, | |
| sub | |
| ) | |
| if sci_cs: | |
| self.timetable[sci_cs][day][p] = { | |
| 'subject': f"{disp} - {t}", | |
| 'teacher': t, | |
| 'raw_sub': sub | |
| } | |
| if art_cs: | |
| self.timetable[art_cs][day][p] = { | |
| 'subject': f"{disp} - {t}", | |
| 'teacher': t, | |
| 'raw_sub': sub | |
| } | |
| teacher_occupied[day][t].add(p) | |
| teacher_daily_load[t][day] += 1 | |
| self.teacher_weekly_load[t] += 1 | |
| remaining -= 1 | |
| assigned = True | |
| break | |
| if assigned: | |
| break | |
| if remaining > 0: | |
| if sci_cs: | |
| self.add_unassigned( | |
| sci_cs, | |
| sub, | |
| remaining, | |
| "Common slot unavailable" | |
| ) | |
| if art_cs: | |
| self.add_unassigned( | |
| art_cs, | |
| sub, | |
| remaining, | |
| "Common slot unavailable" | |
| ) | |
| # ===================================================== | |
| # STEP 2: NORMAL SCHEDULING | |
| # ===================================================== | |
| for cls in sorted_classes: | |
| sections = self.section_df[ | |
| self.section_df['Class'] == cls | |
| ]['Section'].tolist() | |
| # ================================================= | |
| # CLASSES V-X | |
| # Uniform multiplicity without same-teacher same-slot clash | |
| # ================================================= | |
| if self.class_to_num(cls) <= 10: | |
| sections = sorted(sections) | |
| num_sections = len(sections) | |
| # If no section exists, skip safely | |
| if num_sections == 0: | |
| continue | |
| def get_teacher_weekly_limit(teacher): | |
| """ | |
| Uses optional 'Max Periods Per Week' column. | |
| If missing or blank, old behavior is preserved with 999. | |
| """ | |
| if 'Max Periods Per Week' not in self.teachers_df.columns: | |
| return 999 | |
| row = self.teachers_df[ | |
| self.teachers_df['Teachers Name'] == teacher | |
| ] | |
| if row.empty: | |
| return 999 | |
| value = row.iloc[0].get( | |
| 'Max Periods Per Week', | |
| 999 | |
| ) | |
| try: | |
| if pd.isna(value): | |
| return 999 | |
| return int(value) | |
| except (TypeError, ValueError): | |
| return 999 | |
| def find_sectionwise_plan( | |
| teacher, | |
| subject, | |
| count_per_section | |
| ): | |
| """ | |
| Finds a complete section-wise plan. | |
| Conditions: | |
| - Teacher teaches count_per_section periods in EACH section. | |
| - Same teacher is never placed in multiple sections | |
| in the same day-period slot. | |
| - Each section receives same multiplicity. | |
| - Same subject is not repeated on the same day | |
| in the same section. | |
| - Teacher daily load is respected. | |
| - Existing occupied slots are respected. | |
| """ | |
| temp_teacher_occupied = defaultdict(set) | |
| temp_teacher_daily_load = defaultdict(int) | |
| temp_class_subject_days = defaultdict(set) | |
| for d in WORKING_DAYS: | |
| temp_teacher_occupied[d] = set( | |
| teacher_occupied[d][teacher] | |
| ) | |
| temp_teacher_daily_load[d] = teacher_daily_load[ | |
| teacher | |
| ][d] | |
| for sec in sections: | |
| cs = f"{cls}-{sec}" | |
| for d in WORKING_DAYS: | |
| temp_class_subject_days[cs].update( | |
| class_daily_subjects[cs][d] | |
| ) | |
| plan = { | |
| sec: [] | |
| for sec in sections | |
| } | |
| for sec in sections: | |
| cs = f"{cls}-{sec}" | |
| assigned_for_section = 0 | |
| for day in WORKING_DAYS: | |
| if assigned_for_section >= count_per_section: | |
| break | |
| # Existing same-subject-on-same-day restriction | |
| if subject in class_daily_subjects[cs][day]: | |
| continue | |
| # Temporary same-subject-on-same-day restriction | |
| if subject in temp_class_subject_days[f"{cs}-{day}"]: | |
| continue | |
| periods = ( | |
| PERIODS_SATURDAY | |
| if day == 'Saturday' | |
| else PERIODS_WEEKDAYS | |
| ) | |
| for p in periods: | |
| if assigned_for_section >= count_per_section: | |
| break | |
| # Section slot must be free | |
| if p in self.timetable[cs][day]: | |
| continue | |
| # Teacher must not already be occupied | |
| # in this period | |
| if p in temp_teacher_occupied[day]: | |
| continue | |
| # Daily load must allow this period | |
| if ( | |
| temp_teacher_daily_load[day] | |
| >= max_period_lookup.get(teacher, 0) | |
| ): | |
| continue | |
| plan[sec].append( | |
| ( | |
| day, | |
| p | |
| ) | |
| ) | |
| temp_teacher_occupied[day].add(p) | |
| temp_teacher_daily_load[day] += 1 | |
| temp_class_subject_days[f"{cs}-{day}"].add( | |
| subject | |
| ) | |
| assigned_for_section += 1 | |
| # Only one period of the same subject | |
| # per day for the same section | |
| break | |
| if assigned_for_section < count_per_section: | |
| return None | |
| return plan | |
| # ================================================= | |
| # SUBJECT-WISE DISTRIBUTION FOR V-X | |
| # ================================================= | |
| for sub, req in sorted( | |
| class_req[cls].items(), | |
| key=lambda x: x[1], | |
| reverse=True | |
| ): | |
| eligible = self.get_eligible_teachers( | |
| sub, | |
| cls | |
| ) | |
| if not eligible: | |
| for sec in sections: | |
| cs = f"{cls}-{sec}" | |
| self.add_unassigned( | |
| cs, | |
| sub, | |
| req, | |
| "Teacher unavailable" | |
| ) | |
| continue | |
| remaining_per_section = req | |
| # Prefer teachers with lower current weekly load | |
| eligible = sorted( | |
| eligible, | |
| key=lambda t: self.teacher_weekly_load[t] | |
| ) | |
| for teacher in eligible: | |
| if remaining_per_section <= 0: | |
| break | |
| weekly_limit = get_teacher_weekly_limit( | |
| teacher | |
| ) | |
| weekly_remaining_capacity = ( | |
| weekly_limit | |
| - self.teacher_weekly_load[teacher] | |
| ) | |
| if weekly_remaining_capacity <= 0: | |
| continue | |
| # Since the same teacher has to teach each section | |
| # for this share, total required load is: | |
| # share_per_section * number_of_sections | |
| max_possible_per_section = ( | |
| weekly_remaining_capacity | |
| // num_sections | |
| ) | |
| if max_possible_per_section <= 0: | |
| continue | |
| target_per_section = min( | |
| remaining_per_section, | |
| max_possible_per_section | |
| ) | |
| selected_plan = None | |
| selected_count = 0 | |
| # Try largest possible equal section-wise share first | |
| for trial_count in range( | |
| target_per_section, | |
| 0, | |
| -1 | |
| ): | |
| plan = find_sectionwise_plan( | |
| teacher, | |
| sub, | |
| trial_count | |
| ) | |
| if plan: | |
| selected_plan = plan | |
| selected_count = trial_count | |
| break | |
| if not selected_plan: | |
| continue | |
| disp = SUBJECT_ABBR.get( | |
| sub, | |
| sub | |
| ) | |
| # ============================================= | |
| # COMMIT PLAN | |
| # ============================================= | |
| for sec in sections: | |
| cs = f"{cls}-{sec}" | |
| for day, p in selected_plan[sec]: | |
| self.timetable[cs][day][p] = { | |
| 'subject': f"{disp} - {teacher}", | |
| 'teacher': teacher, | |
| 'raw_sub': sub | |
| } | |
| teacher_occupied[day][teacher].add(p) | |
| teacher_daily_load[teacher][day] += 1 | |
| class_daily_subjects[cs][day].add(sub) | |
| self.teacher_weekly_load[teacher] += 1 | |
| remaining_per_section -= selected_count | |
| if self.teacher_weekly_load[teacher] >= weekly_limit: | |
| continue | |
| # ============================================= | |
| # UNASSIGNED REPORT FOR V-X | |
| # ============================================= | |
| if remaining_per_section > 0: | |
| p_map = {} | |
| for d in WORKING_DAYS: | |
| p_map[d] = ( | |
| PERIODS_SATURDAY | |
| if d == 'Saturday' | |
| else PERIODS_WEEKDAYS | |
| ) | |
| reason = self.get_failure_reason( | |
| eligible, | |
| teacher_occupied, | |
| teacher_daily_load, | |
| p_map, | |
| max_period_lookup | |
| ) | |
| if reason == "No free slot": | |
| reason = "Section-wise slot unavailable" | |
| for sec in sections: | |
| cs = f"{cls}-{sec}" | |
| self.add_unassigned( | |
| cs, | |
| sub, | |
| remaining_per_section, | |
| reason | |
| ) | |
| # ================================================= | |
| # CLASSES XI-XII | |
| # Existing stream logic unchanged | |
| # ================================================= | |
| else: | |
| for sec in sections: | |
| cs = f"{cls}-{sec}" | |
| stream_subs = ( | |
| SCIENCE_SUBJECTS | |
| if str(sec).upper() in ['A', 'SCIENCE'] | |
| else ARTS_SUBJECTS | |
| ) | |
| for sub, req in class_req[cls].items(): | |
| if sub not in stream_subs or sub in COMMON_XI_XII: | |
| continue | |
| eligible = self.get_eligible_teachers( | |
| sub, | |
| cls | |
| ) | |
| if not eligible: | |
| self.add_unassigned( | |
| cs, | |
| sub, | |
| req, | |
| "Teacher unavailable" | |
| ) | |
| continue | |
| rem = req | |
| for day in WORKING_DAYS: | |
| if rem <= 0: | |
| break | |
| if sub in class_daily_subjects[cs][day]: | |
| continue | |
| periods = ( | |
| PERIODS_SATURDAY | |
| if day == 'Saturday' | |
| else PERIODS_WEEKDAYS | |
| ) | |
| if ( | |
| cls == 'XI' | |
| and str(sec).upper() in ['A', 'SCIENCE'] | |
| and day in XI_NOPERIOD_6_7_DAYS | |
| ): | |
| periods = periods[:5] | |
| if ( | |
| cls == 'XII' | |
| and str(sec).upper() in ['A', 'SCIENCE'] | |
| and day in XII_NOPERIOD_6_7_DAYS | |
| ): | |
| periods = periods[:5] | |
| for p in periods: | |
| if rem <= 0: | |
| break | |
| existing = self.timetable[cs][day].get(p) | |
| if existing and not self.can_overlap( | |
| sub, | |
| existing['raw_sub'] | |
| ): | |
| continue | |
| for t in eligible: | |
| if ( | |
| p not in teacher_occupied[day][t] | |
| and teacher_daily_load[t][day] < max_period_lookup.get(t, 0) | |
| ): | |
| disp = SUBJECT_ABBR.get( | |
| sub, | |
| sub | |
| ) | |
| if existing: | |
| self.timetable[cs][day][p]['subject'] = ( | |
| f"{self.timetable[cs][day][p]['subject']} " | |
| f"/ {disp} - {t}" | |
| ) | |
| self.timetable[cs][day][p]['teacher'] = ( | |
| f"{self.timetable[cs][day][p].get('teacher', '')} / {t}" | |
| if self.timetable[cs][day][p].get('teacher', '') | |
| else t | |
| ) | |
| else: | |
| self.timetable[cs][day][p] = { | |
| 'subject': f"{disp} - {t}", | |
| 'teacher': t, | |
| 'raw_sub': sub | |
| } | |
| teacher_occupied[day][t].add(p) | |
| teacher_daily_load[t][day] += 1 | |
| class_daily_subjects[cs][day].add( | |
| sub | |
| ) | |
| self.teacher_weekly_load[t] += 1 | |
| rem -= 1 | |
| break | |
| if sub in class_daily_subjects[cs][day]: | |
| break | |
| if rem > 0: | |
| p_map = {} | |
| for d in WORKING_DAYS: | |
| d_p = ( | |
| PERIODS_SATURDAY | |
| if d == 'Saturday' | |
| else PERIODS_WEEKDAYS | |
| ) | |
| if str(sec).upper() in ['A', 'SCIENCE']: | |
| if cls == 'XI' and d in XI_NOPERIOD_6_7_DAYS: | |
| d_p = d_p[:5] | |
| if cls == 'XII' and d in XII_NOPERIOD_6_7_DAYS: | |
| d_p = d_p[:5] | |
| p_map[d] = d_p | |
| reason = self.get_failure_reason( | |
| eligible, | |
| teacher_occupied, | |
| teacher_daily_load, | |
| p_map, | |
| max_period_lookup | |
| ) | |
| self.add_unassigned( | |
| cs, | |
| sub, | |
| rem, | |
| reason | |
| ) | |
| # ===================================================== | |
| # FINAL REPAIR STEP | |
| # ===================================================== | |
| # Important: | |
| # repair_unassigned_classes() repairs one class-section | |
| # at a time. | |
| # | |
| # For V-X, this can break equal multiplicity across sections. | |
| # Therefore: | |
| # - V-X unassigned records are preserved. | |
| # - Only XI-XII unassigned records are repaired. | |
| # ===================================================== | |
| vx_unassigned = [] | |
| repairable_unassigned = [] | |
| for item in self.unassigned: | |
| cls_name = str( | |
| item["Class-Section"] | |
| ).split("-", 1)[0] | |
| if self.class_to_num(cls_name) <= 10: | |
| vx_unassigned.append(item) | |
| else: | |
| repairable_unassigned.append(item) | |
| self.unassigned = repairable_unassigned | |
| self.repair_unassigned_classes( | |
| teacher_occupied, | |
| teacher_daily_load, | |
| class_daily_subjects, | |
| max_period_lookup | |
| ) | |
| # Restore V-X unassigned records after XI-XII repair | |
| self.unassigned = vx_unassigned + self.unassigned | |
| def create_central_routine(self): | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.append(['Day', 'Class', 'Section'] + PERIODS_WEEKDAYS) | |
| for cs in sorted(self.timetable.keys()): | |
| cls, sec = cs.split('-', 1) | |
| for day in WORKING_DAYS: | |
| row = [day, cls, sec] | |
| for p in PERIODS_WEEKDAYS: | |
| entry = self.timetable[cs][day].get(p, {}) | |
| row.append(f"{entry.get('subject','')} - {entry.get('teacher','')}" if entry else "") | |
| ws.append(row) | |
| self.format_excel(ws); path = "/tmp/Central_Routine.xlsx"; wb.save(path); return path | |
| def create_zip(self, filter_func, name): | |
| path = f"/tmp/{name}.zip" | |
| with zipfile.ZipFile(path, 'w') as z: | |
| for cs in sorted(self.timetable.keys()): | |
| if filter_func(cs.split('-')[0]): | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.append( | |
| ['Day'] + PERIODS_WEEKDAYS | |
| ) | |
| for day in WORKING_DAYS: | |
| row = [day] | |
| for p in PERIODS_WEEKDAYS: | |
| entry = self.timetable[ | |
| cs | |
| ][day].get(p, {}) | |
| row.append( | |
| entry.get('subject', '') | |
| ) | |
| ws.append(row) | |
| temp = f"/tmp/Routine_{cs}.xlsx" | |
| self.format_excel(ws) | |
| wb.save(temp) | |
| z.write( | |
| temp, | |
| os.path.basename(temp) | |
| ) | |
| return path | |
| def create_teacher_routine(self): | |
| wb = Workbook() | |
| for i, day in enumerate(WORKING_DAYS): | |
| ws = ( | |
| wb.active | |
| if i == 0 | |
| else wb.create_sheet(day) | |
| ) | |
| ws.title = day | |
| periods = ( | |
| PERIODS_SATURDAY | |
| if day == 'Saturday' | |
| else PERIODS_WEEKDAYS | |
| ) | |
| ws.append( | |
| ['Teacher'] + periods | |
| ) | |
| teacher_map = defaultdict( | |
| lambda: {p: "" for p in periods} | |
| ) | |
| for cs, days in self.timetable.items(): | |
| for p, data in days.get(day, {}).items(): | |
| teacher = data.get( | |
| 'teacher', | |
| '' | |
| ) | |
| if not teacher: | |
| continue | |
| current = teacher_map[ | |
| teacher | |
| ][p] | |
| new_text = ( | |
| f"{cs}" | |
| f"({data['subject']})" | |
| ) | |
| if current: | |
| teacher_map[ | |
| teacher | |
| ][p] = ( | |
| current | |
| + "\n" | |
| + new_text | |
| ) | |
| else: | |
| teacher_map[ | |
| teacher | |
| ][p] = new_text | |
| for t in sorted(self.all_teachers): | |
| row = [t] | |
| for p in periods: | |
| row.append( | |
| teacher_map[t][p] | |
| ) | |
| ws.append(row) | |
| self.format_excel(ws) | |
| path = "/tmp/Teacher_Routine.xlsx" | |
| wb.save(path) | |
| return path | |
| def create_load(self): | |
| wb = Workbook(); ws = wb.active; ws.append(['Teacher', 'Weekly Load']) | |
| for t in sorted(self.all_teachers): ws.append([t, self.teacher_weekly_load[t]]) | |
| self.format_excel(ws); path = "/tmp/Teacher_Load.xlsx"; wb.save(path); return path | |
| def create_unassigned_report(self): | |
| wb = Workbook(); ws = wb.active; ws.append(["Class-Section", "Subject", "Unassigned Periods", "Reason"]) | |
| for row in self.unassigned: ws.append([row["Class-Section"], row["Subject"], row["Unassigned Periods"], row["Reason"]]) | |
| self.format_excel(ws); path = "/tmp/Unassigned_Classes.xlsx"; wb.save(path); return path | |
| def ui_fn(t, d, s): | |
| if not all([t, d, s]): return [None]*6 + ["Error: Upload all files"] | |
| try: | |
| g = TimetableGenerator(); g.load_files(t.name, d.name, s.name); g.generate_routine() | |
| return g.create_central_routine(), g.create_zip(lambda x: g.class_to_num(x)<=10, "V_X"), g.create_zip(lambda x: g.class_to_num(x)>10, "XI_XII"), g.create_teacher_routine(), g.create_load(), g.create_unassigned_report(), "Success" | |
| except Exception as e: return [None]*6 + [f"Error: {str(e)}"] | |
| import gradio as gr | |
| custom_css = """ | |
| body { | |
| background: linear-gradient(to right, #dbeafe, #fce7f3); | |
| } | |
| .gradio-container { | |
| font-family: 'Segoe UI', sans-serif; | |
| } | |
| #title { | |
| text-align: center; | |
| font-size: 38px; | |
| font-weight: bold; | |
| color: #1e3a8a; | |
| margin-bottom: 10px; | |
| } | |
| .upload-box { | |
| border-radius: 18px !important; | |
| border: 2px solid #60a5fa !important; | |
| background: #ffffffdd !important; | |
| } | |
| .output-box { | |
| border-radius: 18px !important; | |
| border: 2px solid #34d399 !important; | |
| background: #f0fdf4 !important; | |
| } | |
| .generate-btn { | |
| background: linear-gradient(to right, #2563eb, #7c3aed) !important; | |
| color: white !important; | |
| border-radius: 14px !important; | |
| font-size: 20px !important; | |
| height: 55px !important; | |
| border: none !important; | |
| } | |
| .generate-btn:hover { | |
| background: linear-gradient(to right, #1d4ed8, #6d28d9) !important; | |
| } | |
| .status-box textarea { | |
| background: #fefce8 !important; | |
| color: #92400e !important; | |
| font-weight: bold !important; | |
| } | |
| """ | |
| with gr.Blocks( | |
| title="School Timetable Generator", | |
| css=custom_css, | |
| theme=gr.themes.Soft( | |
| primary_hue="blue", | |
| secondary_hue="purple", | |
| neutral_hue="slate" | |
| ) | |
| ) as demo: | |
| gr.Image( | |
| value="https://drive.google.com/uc?id=1GrjJOlrbi7rtJksQaidl2ZjugEWzqEFa", | |
| height=500, | |
| width=6000, | |
| show_label=False, | |
| container=False | |
| ) | |
| gr.Markdown( | |
| "# β School Timetable Generator", | |
| elem_id="title" | |
| ) | |
| with gr.Row(): | |
| t_in = gr.File( | |
| label="π Teacher File", | |
| elem_classes="upload-box" | |
| ) | |
| d_in = gr.File( | |
| label="π Distribution File", | |
| elem_classes="upload-box" | |
| ) | |
| s_in = gr.File( | |
| label="π Section File", | |
| elem_classes="upload-box" | |
| ) | |
| btn = gr.Button( | |
| "π Generate Timetable", | |
| elem_classes="generate-btn" | |
| ) | |
| with gr.Row(): | |
| out1 = gr.File( | |
| label="π Central Routine", | |
| elem_classes="output-box" | |
| ) | |
| out2 = gr.File( | |
| label="π« V-X Routine", | |
| elem_classes="output-box" | |
| ) | |
| out3 = gr.File( | |
| label="π XI-XII Routine", | |
| elem_classes="output-box" | |
| ) | |
| out4 = gr.File( | |
| label="π¨βπ« Teacher Routine", | |
| elem_classes="output-box" | |
| ) | |
| out5 = gr.File( | |
| label="π Teacher Load", | |
| elem_classes="output-box" | |
| ) | |
| out6 = gr.File( | |
| label="β οΈ Unassigned Classes", | |
| elem_classes="output-box" | |
| ) | |
| status = gr.Textbox( | |
| label="π’ Status", | |
| lines=3, | |
| elem_classes="status-box" | |
| ) | |
| btn.click( | |
| ui_fn, | |
| [t_in, d_in, s_in], | |
| [out1, out2, out3, out4, out5, out6, status] | |
| ) | |
| demo.launch( | |
| debug=True, | |
| share=True | |
| ) |