optixam / app.py
ChaKaGi's picture
Update app.py
4e51dd0 verified
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 :<br>"
"`exam_id, nb_etudiants, durée, filière[;...], promotion(s)[;...], reprise (optionnel)`<br>"
"Exemples :<br>"
"`0, 30, 2, IA, 1ere, 5`<br>"
"`1, 25, 1, SEIOT;GL, 2eme;3eme`<br><br>"
"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()