import gradio as gr import pandas as pd from ortools.sat.python import cp_model import concurrent.futures def solve_exam_schedule(exams_text, rooms_text, num_slots, conflicts_text, availability_text, invigilators): # --- 1. Parsing des données --- # Ensembles autorisés allowed_filiere = {"IA", "SEIOT", "IM", "SI", "GL"} allowed_promotions = {"1ere", "2eme", "3eme", "master1", "master2"} # Format attendu pour les examens : # exam_id, nb_etudiants, durée, filière[;...], promotion(s)[;...], reprise (optionnel) exams = [] for line in exams_text.strip().splitlines(): if not line.strip(): continue parts = line.split(',') if len(parts) < 5: return "Erreur : chaque ligne d'examen doit avoir au moins 5 valeurs : exam_id, nb_etudiants, durée, filière, promotion." try: exam_id = int(parts[0].strip()) nb_students = int(parts[1].strip()) duration = int(parts[2].strip()) except ValueError: return "Erreur : exam_id, nb_etudiants et durée doivent être des nombres." # Extraction et vérification des filières filiere_str = parts[3].strip() filiere_set = {f.strip() for f in filiere_str.split(';')} if not filiere_set.issubset(allowed_filiere): return f"Erreur : filière(s) non autorisée(s). Autorisées : {', '.join(allowed_filiere)}." # Extraction et vérification des promotions promotion_str = parts[4].strip() promotion_set = {p.strip() for p in promotion_str.split(';')} if not promotion_set.issubset(allowed_promotions): return f"Erreur : promotion(s) non autorisée(s). Autorisées : {', '.join(allowed_promotions)}." reprise = 0 if len(parts) >= 6: try: reprise = int(parts[5].strip()) except ValueError: return "Erreur : la reprise doit être un nombre entier." exams.append((exam_id, nb_students, duration, filiere_set, promotion_set, reprise)) num_exams = len(exams) # Mapping exam_id -> index (pour conflits manuels) exam_id_to_index = {exam[0]: idx for idx, exam in enumerate(exams)} # Lecture des salles : chaque ligne "room_id, capacité" rooms = [] for line in rooms_text.strip().splitlines(): if not line.strip(): continue parts = line.split(',') try: room_id = int(parts[0].strip()) capacity = int(parts[1].strip()) except ValueError: return "Erreur : room_id et capacité doivent être des nombres." rooms.append((room_id, capacity)) num_rooms = len(rooms) # Conflits supplémentaires (manuels) : chaque ligne "exam1, exam2" user_conflicts = [] for line in conflicts_text.strip().splitlines(): if not line.strip(): continue parts = line.split(',') try: e1 = exam_id_to_index[int(parts[0].strip())] e2 = exam_id_to_index[int(parts[1].strip())] user_conflicts.append((e1, e2)) except (KeyError, ValueError): return "Erreur dans les conflits : vérifiez que les exam_id existent et sont valides." # Conflits automatiques : # Deux examens sont en conflit s'ils partagent au moins une filière ou au moins une promotion. auto_conflicts = [] for i in range(num_exams): for j in range(i + 1, num_exams): if exams[i][3].intersection(exams[j][3]) or exams[i][4].intersection(exams[j][4]): auto_conflicts.append((i, j)) all_conflicts = set(user_conflicts + auto_conflicts) # Disponibilité des salles : chaque ligne "room_id, slot1, slot2, ..." (1 = dispo, 0 = non dispo) availability = {} for line in availability_text.strip().splitlines(): if not line.strip(): continue parts = line.split(',') try: room_id = int(parts[0].strip()) except ValueError: return "Erreur : room_id dans la disponibilité doit être un nombre." slots = [] for s in parts[1:]: try: slots.append(int(s.strip())) except ValueError: return "Erreur : les valeurs de disponibilité doivent être 0 ou 1." availability[room_id] = slots # Construction de la matrice de disponibilité dans l'ordre des salles avail_matrix = [] for room in rooms: room_id = room[0] if room_id in availability: avail_matrix.append(availability[room_id]) else: avail_matrix.append([1] * num_slots) # --- 2. Modélisation avec OR-Tools --- model = cp_model.CpModel() # Variable de décision : # x[i, j, k] = 1 si l'examen i est programmé au créneau j dans la salle k. x = {} for i in range(num_exams): for j in range(num_slots): for k in range(num_rooms): x[(i, j, k)] = model.NewBoolVar(f'x_{i}_{j}_{k}') # Chaque examen doit être programmé exactement une fois. for i in range(num_exams): model.Add(sum(x[(i, j, k)] for j in range(num_slots) for k in range(num_rooms)) == 1) # Contrainte de capacité : # Le nombre total d'étudiants (inscrits + reprises) pour les examens programmés dans une salle à un créneau ≤ capacité de la salle. for j in range(num_slots): for k in range(num_rooms): model.Add( sum((exams[i][1] + exams[i][5]) * x[(i, j, k)] for i in range(num_exams)) <= rooms[k][1] ) # Contrainte de conflits : deux examens en conflit ne peuvent être programmés au même créneau. for (i, l) in all_conflicts: for j in range(num_slots): model.Add( sum(x[(i, j, k)] for k in range(num_rooms)) + sum(x[(l, j, k)] for k in range(num_rooms)) <= 1 ) # Contrainte de disponibilité des salles. for j in range(num_slots): for k in range(num_rooms): if avail_matrix[k][j] == 0: for i in range(num_exams): model.Add(x[(i, j, k)] == 0) # --- Nouvelles contraintes supplémentaires --- # 1. Disponibilité des ressources humaines (enseignants surveillants) # Chaque examen requiert un enseignant, donc le nombre total d'examens simultanés ≤ nombre d'enseignants disponibles. for j in range(num_slots): model.Add(sum(x[(i, j, k)] for i in range(num_exams) for k in range(num_rooms)) <= invigilators) # 2. Un étudiant (promotion) ne peut être inscrit à deux examens simultanément # Pour chaque promotion et chaque créneau, au plus un examen (contenant cette promotion) peut être programmé. for p in allowed_promotions: for j in range(num_slots): model.Add(sum(x[(i, j, k)] for i in range(num_exams) if p in exams[i][4] for k in range(num_rooms)) <= 1) # 3. Marges de transition dans chaque salle # Si une salle est utilisée au créneau j, elle doit rester libre au créneau j+1. for k in range(num_rooms): for j in range(num_slots - 1): model.Add( sum(x[(i, j, k)] for i in range(num_exams)) + sum(x[(i, j+1, k)] for i in range(num_exams)) <= 1 ) # Objectif : Minimiser le dernier créneau utilisé (T_max) T_max = model.NewIntVar(0, num_slots - 1, 'T_max') y = {} for i in range(num_exams): for j in range(num_slots): y[(i, j)] = model.NewBoolVar(f'y_{i}_{j}') model.Add(sum(x[(i, j, k)] for k in range(num_rooms)) == y[(i, j)]) for i in range(num_exams): for j in range(num_slots): model.Add(T_max >= j).OnlyEnforceIf(y[(i, j)]) model.Minimize(T_max) # --- 3. Résolution --- solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = 10 # Limite de temps pour éviter un blocage trop long status = solver.Solve(model) if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: schedule = [] for i in range(num_exams): for j in range(num_slots): for k in range(num_rooms): if solver.Value(x[(i, j, k)]) == 1: exam_id = exams[i][0] nb_students = exams[i][1] duration = exams[i][2] filiere = ";".join(sorted(list(exams[i][3]))) promotions = ";".join(sorted(list(exams[i][4]))) reprise = exams[i][5] room_id = rooms[k][0] schedule.append({ "Examen": exam_id, "Filière": filiere, "Promotion": promotions, "Créneau": j, "Salle": room_id, "Nb Étudiants": nb_students, "Reprise": reprise, "Durée (h)": duration }) schedule = sorted(schedule, key=lambda item: item["Créneau"]) df = pd.DataFrame(schedule) return df else: # En cas d'erreur, on retourne un DataFrame avec un message return pd.DataFrame([{"Message": "Aucune solution trouvée."}]) # Exécution dans un thread séparé executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) def solve_in_thread(*args, **kwargs): future = executor.submit(solve_exam_schedule, *args, **kwargs) return future.result() # Interface Gradio with gr.Blocks(css=".gradio-container {max-width: 900px; margin: auto;}") as demo: gr.Markdown("# Planification des Examens Multi-Promotions, Filières et Contraintes de Ressources") gr.Markdown( "Format des examens :
" "`exam_id, nb_etudiants, durée, filière[;...], promotion(s)[;...], reprise (optionnel)`
" "Exemples :
" "`0, 30, 2, IA, 1ere, 5`
" "`1, 25, 1, SEIOT;GL, 2eme;3eme`

" "Vous pouvez modifier directement le planning dans le tableau ci-dessous une fois généré." ) with gr.Row(): exams_input = gr.Textbox( label="Liste des examens", lines=5, value="0, 30, 2, IA, 1ere, 5\n1, 25, 1, SEIOT, 2eme, 0\n2, 40, 2, IA;GL, 3eme, 0\n3, 20, 1, SI, master1, 0" ) with gr.Row(): rooms_input = gr.Textbox( label="Liste des salles (room_id, capacité)", lines=2, value="0, 50\n1, 30" ) with gr.Row(): num_slots_input = gr.Slider( label="Nombre de créneaux", minimum=1, maximum=10, step=1, value=4 ) with gr.Row(): conflicts_input = gr.Textbox( label="Conflits supplémentaires (exam1, exam2) [optionnel]", lines=2, value="1, 3" ) with gr.Row(): availability_input = gr.Textbox( label="Disponibilité des salles (room_id, slot1, slot2, ... avec 1 pour dispo, 0 sinon)", lines=2, value="0, 1, 1, 1, 1\n1, 1, 1, 0, 1" ) with gr.Row(): invigilators_input = gr.Slider( label="Nombre d'enseignants disponibles par créneau", minimum=1, maximum=10, step=1, value=3 ) # Composant DataFrame interactif pour afficher et modifier le planning schedule_output = gr.Dataframe(headers=["Examen", "Filière", "Promotion", "Créneau", "Salle", "Nb Étudiants", "Reprise", "Durée (h)"], label="Calendrier des examens (modifiable)", interactive=True) solve_btn = gr.Button("Planifier les examens") solve_btn.click( fn=solve_in_thread, inputs=[exams_input, rooms_input, num_slots_input, conflicts_input, availability_input, invigilators_input], outputs=schedule_output ) demo.launch()