File size: 12,145 Bytes
48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 98aaac2 4e51dd0 48eaef5 98aaac2 48eaef5 98aaac2 4e51dd0 98aaac2 4e51dd0 98aaac2 48eaef5 4e51dd0 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 4e51dd0 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 4e51dd0 48eaef5 98aaac2 48eaef5 4e51dd0 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 4e51dd0 48eaef5 4e51dd0 48eaef5 98aaac2 4e51dd0 48eaef5 98aaac2 48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 4e51dd0 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 98aaac2 4e51dd0 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 98aaac2 48eaef5 4e51dd0 48eaef5 4e51dd0 48eaef5 | 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 | 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()
|