GiGi2k5 commited on
Commit
28d83d0
·
1 Parent(s): 34ecbd9

new files

Browse files
Files changed (2) hide show
  1. app.py +256 -0
  2. requirements.txt +1 -0
app.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import pandas as pd
3
+ from pulp import LpProblem, LpMinimize, LpVariable, lpSum, LpBinary, LpStatus
4
+ import concurrent.futures
5
+
6
+ def solve_exam_schedule_mip(exams_text, rooms_text, num_slots, conflicts_text, availability_text, invigilators):
7
+ # --- 1. Parsing des données ---
8
+ # Ensembles autorisés pour filières et promotions
9
+ allowed_filiere = {"IA", "SEIOT", "IM", "SI", "GL"}
10
+ allowed_promotions = {"1ere", "2eme", "3eme", "master1", "master2"}
11
+
12
+ # Format attendu pour les examens :
13
+ # exam_id, nb_etudiants, durée, filière[;...], promotion(s)[;...], reprise (optionnel)
14
+ exams = []
15
+ for line in exams_text.strip().splitlines():
16
+ if not line.strip():
17
+ continue
18
+ parts = line.split(',')
19
+ if len(parts) < 5:
20
+ return "Erreur : chaque ligne d'examen doit avoir au moins 5 valeurs : exam_id, nb_etudiants, durée, filière, promotion."
21
+ try:
22
+ exam_id = int(parts[0].strip())
23
+ nb_students = int(parts[1].strip())
24
+ duration = int(parts[2].strip())
25
+ except ValueError:
26
+ return "Erreur : exam_id, nb_etudiants et durée doivent être des nombres."
27
+ filiere_set = {f.strip() for f in parts[3].split(';')}
28
+ if not filiere_set.issubset(allowed_filiere):
29
+ return f"Erreur : filière(s) non autorisée(s). Autorisées : {', '.join(allowed_filiere)}."
30
+ promotion_set = {p.strip() for p in parts[4].split(';')}
31
+ if not promotion_set.issubset(allowed_promotions):
32
+ return f"Erreur : promotion(s) non autorisée(s). Autorisées : {', '.join(allowed_promotions)}."
33
+ reprise = 0
34
+ if len(parts) >= 6:
35
+ try:
36
+ reprise = int(parts[5].strip())
37
+ except ValueError:
38
+ return "Erreur : la reprise doit être un nombre entier."
39
+ exams.append((exam_id, nb_students, duration, filiere_set, promotion_set, reprise))
40
+ num_exams = len(exams)
41
+
42
+ # Mapping exam_id -> index (pour gérer les conflits manuels)
43
+ exam_id_to_index = {exam[0]: idx for idx, exam in enumerate(exams)}
44
+
45
+ # Lecture des salles : chaque ligne "room_id, capacité"
46
+ rooms = []
47
+ for line in rooms_text.strip().splitlines():
48
+ if not line.strip():
49
+ continue
50
+ parts = line.split(',')
51
+ try:
52
+ room_id = int(parts[0].strip())
53
+ capacity = int(parts[1].strip())
54
+ except ValueError:
55
+ return "Erreur : room_id et capacité doivent être des nombres."
56
+ rooms.append((room_id, capacity))
57
+ num_rooms = len(rooms)
58
+
59
+ # Conflits supplémentaires (manuels) : chaque ligne "exam1, exam2"
60
+ manual_conflicts = []
61
+ for line in conflicts_text.strip().splitlines():
62
+ if not line.strip():
63
+ continue
64
+ parts = line.split(',')
65
+ try:
66
+ e1 = exam_id_to_index[int(parts[0].strip())]
67
+ e2 = exam_id_to_index[int(parts[1].strip())]
68
+ manual_conflicts.append((e1, e2))
69
+ except (KeyError, ValueError):
70
+ return "Erreur dans les conflits manuels : vérifiez que les exam_id existent."
71
+
72
+ # Conflits automatiques : deux examens sont en conflit s'ils partagent au moins une filière ou au moins une promotion
73
+ auto_conflicts = []
74
+ for i in range(num_exams):
75
+ for j in range(i+1, num_exams):
76
+ if exams[i][3].intersection(exams[j][3]) or exams[i][4].intersection(exams[j][4]):
77
+ auto_conflicts.append((i, j))
78
+ all_conflicts = set(manual_conflicts + auto_conflicts)
79
+
80
+ # Disponibilité des salles : chaque ligne "room_id, slot1, slot2, ...", 1 = disponible, 0 = non disponible
81
+ availability = {}
82
+ for line in availability_text.strip().splitlines():
83
+ if not line.strip():
84
+ continue
85
+ parts = line.split(',')
86
+ try:
87
+ room_id = int(parts[0].strip())
88
+ slots = [int(s.strip()) for s in parts[1:]]
89
+ except:
90
+ return "Erreur dans la disponibilité : vérifiez le format."
91
+ availability[room_id] = slots
92
+ # Construction de la matrice de disponibilité dans l'ordre des salles fournies
93
+ avail_matrix = []
94
+ for room in rooms:
95
+ room_id = room[0]
96
+ if room_id in availability:
97
+ avail_matrix.append(availability[room_id])
98
+ else:
99
+ avail_matrix.append([1] * num_slots)
100
+
101
+ # --- 2. Formulation MILP avec PuLP ---
102
+ prob = LpProblem("Exam_Scheduling", LpMinimize)
103
+
104
+ # Variables de décision :
105
+ # x[i,j,k] = 1 si l'examen i est programmé au créneau j dans la salle k.
106
+ x = {}
107
+ for i in range(num_exams):
108
+ for j in range(num_slots):
109
+ for k in range(num_rooms):
110
+ x[(i,j,k)] = LpVariable(f"x_{i}_{j}_{k}", cat=LpBinary)
111
+
112
+ # Pour gérer la marge de transition, on définit y[j,k] = 1 si la salle k est utilisée au créneau j.
113
+ y = {}
114
+ for j in range(num_slots):
115
+ for k in range(num_rooms):
116
+ y[(j,k)] = LpVariable(f"y_{j}_{k}", cat=LpBinary)
117
+
118
+ # Variables T[j] indiquant si le créneau j est utilisé (pour minimiser le nombre de créneaux)
119
+ T = {}
120
+ for j in range(num_slots):
121
+ T[j] = LpVariable(f"T_{j}", cat=LpBinary)
122
+
123
+ # Objectif : Minimiser le nombre total de créneaux utilisés
124
+ prob += lpSum([T[j] for j in range(num_slots)]), "Minimize_total_slots"
125
+
126
+ # Contrainte 1 : chaque examen est programmé exactement une fois
127
+ for i in range(num_exams):
128
+ prob += lpSum([x[(i,j,k)] for j in range(num_slots) for k in range(num_rooms)]) == 1, f"Exam_{i}_once"
129
+
130
+ # Contrainte 2 : capacité de chaque salle à chaque créneau
131
+ for j in range(num_slots):
132
+ for k in range(num_rooms):
133
+ prob += lpSum([(exams[i][1] + exams[i][5]) * x[(i,j,k)] for i in range(num_exams)]) <= rooms[k][1], f"Capacity_slot_{j}_room_{k}"
134
+
135
+ # Contrainte 3 : conflits – deux examens en conflit ne peuvent être programmés au même créneau
136
+ for (i, l) in all_conflicts:
137
+ for j in range(num_slots):
138
+ prob += lpSum([x[(i,j,k)] for k in range(num_rooms)]) + lpSum([x[(l,j,k)] for k in range(num_rooms)]) <= 1, f"Conflict_{i}_{l}_slot_{j}"
139
+
140
+ # Contrainte 4 : disponibilité des salles
141
+ for j in range(num_slots):
142
+ for k in range(num_rooms):
143
+ if avail_matrix[k][j] == 0:
144
+ for i in range(num_exams):
145
+ prob += x[(i,j,k)] == 0, f"Avail_room_{k}_slot_{j}_exam_{i}"
146
+
147
+ # Contrainte 5 : disponibilité des enseignants (ressources humaines)
148
+ for j in range(num_slots):
149
+ prob += lpSum([x[(i,j,k)] for i in range(num_exams) for k in range(num_rooms)]) <= invigilators, f"Teachers_slot_{j}"
150
+
151
+ # Contrainte 6 : un étudiant (promotion) ne peut avoir deux examens simultanés
152
+ for p in allowed_promotions:
153
+ for j in range(num_slots):
154
+ prob += lpSum([x[(i,j,k)] for i in range(num_exams) if p in exams[i][4] for k in range(num_rooms)]) <= 1, f"Promotion_{p}_slot_{j}"
155
+
156
+ # Contrainte 7 : marges de transition dans chaque salle
157
+ # On lie d'abord y[j,k] à l'utilisation de la salle
158
+ for j in range(num_slots):
159
+ for k in range(num_rooms):
160
+ prob += y[(j,k)] >= lpSum([x[(i,j,k)] for i in range(num_exams)])/100.0, f"Link_y_lower_{j}_{k}"
161
+ prob += y[(j,k)] <= lpSum([x[(i,j,k)] for i in range(num_exams)]), f"Link_y_upper_{j}_{k}"
162
+ # Puis, pour chaque salle, deux créneaux consécutifs ne peuvent être utilisés
163
+ for k in range(num_rooms):
164
+ for j in range(num_slots - 1):
165
+ prob += y[(j,k)] + y[(j+1,k)] <= 1, f"Transition_room_{k}_slots_{j}_{j+1}"
166
+
167
+ # Contrainte 8 : liaison des variables T[j] : T[j] = 1 si un examen est programmé en slot j
168
+ for j in range(num_slots):
169
+ prob += T[j] >= lpSum([x[(i,j,k)] for i in range(num_exams) for k in range(num_rooms)])/100.0, f"Link_T_lower_{j}"
170
+ prob += T[j] <= lpSum([x[(i,j,k)] for i in range(num_exams) for k in range(num_rooms)]), f"Link_T_upper_{j}"
171
+
172
+ # --- 3. Résolution ---
173
+ prob.solve()
174
+
175
+ if LpStatus[prob.status] in ["Optimal", "Feasible"]:
176
+ schedule = []
177
+ for i in range(num_exams):
178
+ for j in range(num_slots):
179
+ for k in range(num_rooms):
180
+ if x[(i,j,k)].varValue is not None and x[(i,j,k)].varValue > 0.5:
181
+ schedule.append({
182
+ "Examen": exams[i][0],
183
+ "Filière": ";".join(sorted(list(exams[i][3]))),
184
+ "Promotion": ";".join(sorted(list(exams[i][4]))),
185
+ "Créneau": j,
186
+ "Salle": rooms[k][0],
187
+ "Nb Étudiants": exams[i][1],
188
+ "Reprise": exams[i][5],
189
+ "Durée (h)": exams[i][2]
190
+ })
191
+ schedule = sorted(schedule, key=lambda s: s["Créneau"])
192
+ df = pd.DataFrame(schedule)
193
+ return df
194
+ else:
195
+ return pd.DataFrame([{"Message": "Aucune solution trouvée."}])
196
+
197
+ # Exécuter dans un thread séparé pour ne pas bloquer l'interface Gradio
198
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
199
+ def solve_in_thread(*args, **kwargs):
200
+ future = executor.submit(solve_exam_schedule_mip, *args, **kwargs)
201
+ return future.result()
202
+
203
+ with gr.Blocks(css=".gradio-container {max-width: 900px; margin: auto;}") as demo:
204
+ gr.Markdown("# Planification des Examens Multi-Promotions, Filières, avec MIP (PuLP)")
205
+ gr.Markdown(
206
+ "Format des examens :<br>"
207
+ "`exam_id, nb_etudiants, durée, filière[;...], promotion(s)[;...], reprise (optionnel)`<br>"
208
+ "Exemples :<br>"
209
+ "`0, 30, 2, IA, 1ere, 5`<br>"
210
+ "`1, 25, 1, SEIOT;GL, 2eme;3eme`"
211
+ )
212
+
213
+ with gr.Row():
214
+ exams_input = gr.Textbox(
215
+ label="Liste des examens",
216
+ lines=5,
217
+ 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"
218
+ )
219
+ with gr.Row():
220
+ rooms_input = gr.Textbox(
221
+ label="Liste des salles (room_id, capacité)",
222
+ lines=2,
223
+ value="0, 50\n1, 30\n2, 40"
224
+ )
225
+ with gr.Row():
226
+ num_slots_input = gr.Slider(
227
+ label="Nombre de créneaux", minimum=1, maximum=10, step=1, value=5
228
+ )
229
+ with gr.Row():
230
+ conflicts_input = gr.Textbox(
231
+ label="Conflits supplémentaires (exam1, exam2) [optionnel]",
232
+ lines=2,
233
+ value="1, 3"
234
+ )
235
+ with gr.Row():
236
+ availability_input = gr.Textbox(
237
+ label="Disponibilité des salles (room_id, slot1, slot2, ... avec 1 pour dispo, 0 sinon)",
238
+ lines=2,
239
+ value="0, 1, 1, 1, 1, 1\n1, 1, 1, 0, 1, 1\n2, 1, 1, 1, 1, 0"
240
+ )
241
+ with gr.Row():
242
+ invigilators_input = gr.Slider(
243
+ label="Nombre d'enseignants disponibles par créneau", minimum=1, maximum=10, step=1, value=3
244
+ )
245
+
246
+ schedule_output = gr.Dataframe(headers=["Examen", "Filière", "Promotion", "Créneau", "Salle", "Nb Étudiants", "Reprise", "Durée (h)"],
247
+ label="Calendrier des examens (modifiable)", interactive=True)
248
+
249
+ solve_btn = gr.Button("Planifier les examens")
250
+ solve_btn.click(
251
+ fn=solve_in_thread,
252
+ inputs=[exams_input, rooms_input, num_slots_input, conflicts_input, availability_input, invigilators_input],
253
+ outputs=schedule_output
254
+ )
255
+
256
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ pulp