Kacemath commited on
Commit
b43bf80
·
1 Parent(s): 328f83d
achref/README.md DELETED
@@ -1,60 +0,0 @@
1
- # Satellite Collision Avoidance Web App
2
-
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
-
5
- A web-based interface for optimizing satellite trajectories to prevent collisions while minimizing fuel consumption and deviation from uncorrected paths. Built with Python/Gurobi for optimization and Three.js for 3D visualization.
6
-
7
- ![App Screenshot](./assets/satellite_trajectories.gif)
8
-
9
- ### Optimization Objective
10
-
11
- We now jointly minimize fuel usage and trajectory deviation from the nominal (no-thrust) path:
12
-
13
- ```math
14
- \text{Minimize } \sum_{i=1}^N \sum_{t=0}^{T-1} \delta_{i,t}
15
- \;+
16
- \;\lambda \sum_{i=1}^N \sum_{t=0}^{T} \sum_{a\in\{x,y,z\}} d_{i,t}^a
17
- ```
18
-
19
- where:
20
-
21
- * \$\delta\_{i,t} \ge c\_{\text{fuel}}\sum\_{a\in{x,y,z}} |u\_{i,t}^a|\$ (fuel proxy)
22
- * \$d\_{i,t}^a \ge |p\_{i,t}^a - p\_{i,t}^{a,\mathrm{nom}}|\$ (deviation from nominal trajectory)
23
- * \$\lambda\$ is an optional weight balancing fuel vs. deviation (default \$\lambda=1\$).
24
-
25
- ### Features
26
-
27
- * Interactive 3D visualization of satellite trajectories
28
- * Configurable safety margins, thrust limits, and deviation weight
29
- * Real-time MILP optimization backend
30
- * Scenario export/import in JSON format
31
-
32
- ## Problem Formulation
33
-
34
- ### Orbital Dynamics
35
-
36
- For satellite \$i\$ at time \$t\$ with mass \$m\_i\$ and thrust \$\mathbf{u}\_{i,t}\$:
37
-
38
- ```math
39
- \begin{aligned}
40
- \mathbf{p}_{i,t+1} &= \mathbf{p}_{i,t} + \mathbf{v}_{i,t} \Delta t + \frac{1}{2m_i} \mathbf{u}_{i,t} (\Delta t)^2 \\
41
- \mathbf{v}_{i,t+1} &= \mathbf{v}_{i,t} + \frac{1}{m_i} \mathbf{u}_{i,t} \Delta t
42
- \end{aligned}
43
- ```
44
-
45
- ### Collision Avoidance
46
-
47
- For all satellite pairs \$(i,j)\$ and times \$t \ge 1\$:
48
-
49
- ```math
50
- \begin{aligned}
51
- |p_{i,t}^x - p_{j,t}^x| &\ge d_{\text{safe}} \cdot b_{ij,t}^x \\
52
- |p_{i,t}^y - p_{j,t}^y| &\ge d_{\text{safe}} \cdot b_{ij,t}^y \\
53
- |p_{i,t}^z - p_{j,t}^z| &\ge d_{\text{safe}} \cdot b_{ij,t}^z \\
54
- b_{ij,t}^x + b_{ij,t}^y + b_{ij,t}^z &\ge 1 \quad (b_{ij,t}^a \in \{0,1\})
55
- \end{aligned}
56
- ```
57
-
58
- ### Additional Resources
59
-
60
- For a comprehensive explanation of the problem statement, including mathematical formulations and assumptions, refer to the [Problem Statement](./assets/problem_statement.md). This document provides in-depth details about the orbital dynamics, collision avoidance constraints, and optimization objectives used in this project.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
achref/infeasible.ilp DELETED
@@ -1,36 +0,0 @@
1
- \ Model SatelliteLP_WithTracking_copy
2
- \ LP format - for model browsing. Use MPS format to capture full model detail.
3
- Minimize
4
-
5
- Subject To
6
- init_p_Sat1_1: p_Sat1_0[1] = 0
7
- init_v_Sat1_1: v_Sat1_0[1] = 0
8
- dyn_p_Sat1_0_1: - 0.0005 u_Sat1_0[1] - p_Sat1_0[1] + p_Sat1_1[1]
9
- - 0.1 v_Sat1_0[1] = 0
10
- dyn_v_Sat1_0_1: - 0.01 u_Sat1_0[1] - v_Sat1_0[1] + v_Sat1_1[1] = 0
11
- dyn_p_Sat1_1_1: - 0.0005 u_Sat1_1[1] - p_Sat1_1[1] + p_Sat1_2[1]
12
- - 0.1 v_Sat1_1[1] = 0
13
- init_p_Sat2_1: p_Sat2_0[1] = 1.732051
14
- init_v_Sat2_1: v_Sat2_0[1] = -0.0866025
15
- dyn_p_Sat2_0_1: - 0.0005 u_Sat2_0[1] - p_Sat2_0[1] + p_Sat2_1[1]
16
- - 0.1 v_Sat2_0[1] = 0
17
- dyn_v_Sat2_0_1: - 0.01 u_Sat2_0[1] - v_Sat2_0[1] + v_Sat2_1[1] = 0
18
- dyn_p_Sat2_1_1: - 0.0005 u_Sat2_1[1] - p_Sat2_1[1] + p_Sat2_2[1]
19
- - 0.1 v_Sat2_1[1] = 0
20
- con_dy_Sat1_Sat2_2: - p_Sat1_2[1] + p_Sat2_2[1] + dy_Sat1_Sat2_2 = 0
21
- Bounds
22
- -infinity <= u_Sat1_0[1] <= 100
23
- -infinity <= u_Sat1_1[1] <= 100
24
- u_Sat2_0[1] >= -100
25
- u_Sat2_1[1] >= -100
26
- p_Sat1_0[1] free
27
- p_Sat1_1[1] free
28
- p_Sat1_2[1] free
29
- v_Sat1_0[1] free
30
- v_Sat1_1[1] free
31
- p_Sat2_0[1] free
32
- p_Sat2_1[1] free
33
- p_Sat2_2[1] free
34
- v_Sat2_0[1] free
35
- v_Sat2_1[1] free
36
- End
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
achref/src/config/__init__.py DELETED
@@ -1 +0,0 @@
1
- from .config import get_settings
 
 
achref/src/config/config.py DELETED
@@ -1,39 +0,0 @@
1
- import os
2
- from functools import lru_cache
3
- from pydantic_settings import BaseSettings
4
- from dotenv import load_dotenv
5
-
6
- load_dotenv(".env")
7
-
8
- class Settings(BaseSettings):
9
- """
10
- Settings class for this application.
11
- Utilizes the BaseSettings from Pydantic for environment variables.
12
- """
13
- # Python setup
14
- PYTHONPATH: str = None
15
-
16
- class Config:
17
- env_file = ".env"
18
- extra = "ignore"
19
-
20
-
21
- @lru_cache(maxsize=None)
22
- def get_settings() -> Settings:
23
- """
24
- Function to get and cache settings.
25
- The settings are cached to avoid repeated disk I/O.
26
- """
27
- environment = os.getenv("ENVIRONMENT", "local")
28
-
29
- if environment == "local":
30
- settings_file = ".env"
31
- elif environment == "dev":
32
- settings_file = ".env.dev"
33
- elif environment == "prod":
34
- settings_file = ".env.prod"
35
- else:
36
- raise ValueError(f"Invalid environment: {environment}")
37
-
38
- # Load settings from the respective .env file
39
- return Settings(_env_file=settings_file) # type: ignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
achref/src/logger/__init__.py DELETED
@@ -1 +0,0 @@
1
- from .logger import get_logger
 
 
achref/src/models/gorubi_models.py DELETED
@@ -1,255 +0,0 @@
1
- import gurobipy as gp
2
- from gurobipy import GRB
3
- from itertools import combinations
4
- import matplotlib.pyplot as plt
5
- from mpl_toolkits.mplot3d import Axes3D
6
- import re
7
- import matplotlib.animation as animation
8
-
9
- def animate_satellite_trajectories(model, time_steps, interval=200):
10
- """
11
- Creates a 3D animated plot of satellite trajectories and thrust vectors.
12
-
13
- Args:
14
- model (gp.Model): Solved Gurobi model containing:
15
- - position variables named `p_{sat}_{t}[coord]`
16
- - thrust variables named `u_{sat}_{t}[coord]`
17
- time_steps (list of int): List of time step indices used in the optimization.
18
- interval (int): Delay between frames in milliseconds.
19
- """
20
- # Extract data containers
21
- positions = {}
22
- thrusts = {}
23
- # Patterns to match variable names
24
- p_pattern = re.compile(r'p_(\w+)_(\d+)\[(\d+)\]')
25
- u_pattern = re.compile(r'u_(\w+)_(\d+)\[(\d+)\]')
26
-
27
- # Populate positions and thrusts dictionaries
28
- for var in model.getVars():
29
- # Position variables
30
- pm = p_pattern.match(var.VarName)
31
- if pm:
32
- sat, t_str, coord_str = pm.groups()
33
- t = int(t_str)
34
- coord = int(coord_str)
35
- positions.setdefault(sat, {}).setdefault(t, [0.0, 0.0, 0.0])[coord] = var.Xn
36
- continue
37
- # Thrust variables
38
- um = u_pattern.match(var.VarName)
39
- if um:
40
- sat, t_str, coord_str = um.groups()
41
- t = int(t_str)
42
- coord = int(coord_str)
43
- thrusts.setdefault(sat, {}).setdefault(t, [0.0, 0.0, 0.0])[coord] = var.Xn
44
-
45
- sats = sorted(positions.keys())
46
-
47
- # Prepare 3D figure
48
- fig = plt.figure(figsize=(10, 8))
49
- ax = fig.add_subplot(111, projection='3d')
50
- ax.set_xlabel('X (m)')
51
- ax.set_ylabel('Y (m)')
52
- ax.set_zlabel('Z (m)')
53
- ax.set_title('Satellite Trajectories and Thrust Animation')
54
-
55
- # Plot objects for trajectories, end markers, and quivers
56
- traj_lines = {sat: ax.plot([], [], [], label=f'{sat} traj')[0] for sat in sats}
57
- end_markers = {sat: ax.plot([], [], [], marker='o', linestyle='')[0] for sat in sats}
58
- quiver_objs = {sat: None for sat in sats}
59
-
60
- # Set axis limits based on all positions
61
- all_x = [positions[s][t][0] for s in sats for t in positions[s]]
62
- all_y = [positions[s][t][1] for s in sats for t in positions[s]]
63
- all_z = [positions[s][t][2] for s in sats for t in positions[s]]
64
- ax.set_xlim(min(all_x), max(all_x))
65
- ax.set_ylim(min(all_y), max(all_y))
66
- ax.set_zlim(min(all_z), max(all_z))
67
- ax.legend()
68
-
69
- def update(frame):
70
- # Remove previous quivers
71
- for sat in sats:
72
- if quiver_objs[sat] is not None:
73
- quiver_objs[sat].remove()
74
-
75
- # Update each satellite's trajectory, marker, and thrust arrow
76
- for sat in sats:
77
- # Trajectory up to current frame
78
- xs = [positions[sat][t][0] for t in time_steps if t <= frame]
79
- ys = [positions[sat][t][1] for t in time_steps if t <= frame]
80
- zs = [positions[sat][t][2] for t in time_steps if t <= frame]
81
- traj_lines[sat].set_data(xs, ys)
82
- traj_lines[sat].set_3d_properties(zs)
83
-
84
- # End marker at last position
85
- if xs:
86
- end_markers[sat].set_data([xs[-1]], [ys[-1]])
87
- end_markers[sat].set_3d_properties([zs[-1]])
88
-
89
- # Thrust arrow at this frame
90
- ux, uy, uz = thrusts.get(sat, {}).get(frame, [0.0, 0.0, 0.0])
91
- # Draw new quiver
92
- quiver_objs[sat] = ax.quiver(
93
- xs[-1], ys[-1], zs[-1], ux, uy, uz,
94
- length=1e3, normalize=True
95
- )
96
-
97
- # Return artists to update
98
- artists = list(traj_lines.values()) + list(end_markers.values())
99
- artists += [q for q in quiver_objs.values() if q is not None]
100
- return artists
101
-
102
- # Create animation
103
- ani = animation.FuncAnimation(
104
- fig, update, frames=time_steps, interval=interval, blit=False
105
- )
106
- ani.save("satellite_trajectories.gif", writer='imagemagick', fps=5)
107
- plt.show()
108
- # ---------------------------
109
- # SCALED DATA INITIALIZATION
110
- # ---------------------------
111
- # Units:
112
- # • distance unit = 10 km
113
- # • time unit = 10 s
114
- # • velocity unit = 1 km/s
115
- # • thrust unit = 1 kN
116
- # • mass unit = 0.01 kg
117
-
118
- time_steps = list(range(20))
119
- dt = 10.0 # 10 s
120
- F_max = 10.0 # 100 kN
121
- c_fuel = 100 # same relative cost
122
- m_i = 1000.0 # 0.01 kg
123
- # safety distance scaled: 0.00001 units = 0.1 m
124
- # we apply collision avoidance only from t=1 onward to respect initial positions
125
- d_safe = 1
126
-
127
- initial_state = {
128
- "Sat1": { "position": [20000/10000, 0.0, 0.0], "velocity": [-100/1000, 0.0, 0.0] },
129
- "Sat2": { "position": [-10000/10000, 17320.51/10000, 0.0], "velocity": [ 50/1000, -86.6025/1000, 0.0] },
130
- "Sat3": { "position": [-10000/10000,-17320.51/10000, 0.0], "velocity": [ 50/1000, 86.6025/1000, 0.0] },
131
- }
132
- satellites = list(initial_state.keys())
133
-
134
- # ------------------------------------
135
- # PRECOMPUTE NOMINAL (UNCORRECTED) TRAJECTORIES
136
- # ------------------------------------
137
- # Nominal case: no thrust (u=0)
138
- nominal_positions = {sat: {0: initial_state[sat]['position'][:] } for sat in satellites}
139
- for sat in satellites:
140
- pos = nominal_positions[sat]
141
- vel = initial_state[sat]['velocity']
142
- for t in time_steps[:-1]:
143
- # constant velocity motion
144
- next_pos = [pos[t][i] + vel[i] * dt for i in range(3)]
145
- pos[t+1] = next_pos
146
-
147
- # ------------------------------------
148
- # ORBITAL DYNAMICS FUNCTION
149
- # ------------------------------------
150
- def add_orbital_dynamics(model, sat, time_steps, dt, mass, u, initial_pos, initial_vel):
151
- p = {t: model.addVars(3, lb=-GRB.INFINITY, ub=GRB.INFINITY, name=f"p_{sat}_{t}") for t in time_steps}
152
- v = {t: model.addVars(3, lb=-GRB.INFINITY, ub=GRB.INFINITY, name=f"v_{sat}_{t}") for t in time_steps}
153
-
154
- # initial conditions
155
- for coord in range(3):
156
- model.addConstr(p[0][coord] == initial_pos[sat][coord], name=f"init_p_{sat}_{coord}")
157
- model.addConstr(v[0][coord] == initial_vel[sat][coord], name=f"init_v_{sat}_{coord}")
158
-
159
- # dynamics for t=0..T-1
160
- for t in time_steps[:-1]:
161
- for coord in range(3):
162
- model.addConstr(
163
- p[t+1][coord] == p[t][coord]
164
- + v[t][coord] * dt
165
- + 0.5 * (u[sat, t][coord] / mass) * dt * dt,
166
- name=f"dyn_p_{sat}_{t}_{coord}"
167
- )
168
- model.addConstr(
169
- v[t+1][coord] == v[t][coord]
170
- + (u[sat, t][coord] / mass) * dt,
171
- name=f"dyn_v_{sat}_{t}_{coord}"
172
- )
173
- return p, v
174
-
175
- # ---------------------------
176
- # BUILD & SOLVE WITH TRACKING PENALTY
177
- # ---------------------------
178
-
179
- def solve_lp_model():
180
- model = gp.Model("SatelliteLP_WithTracking")
181
-
182
- # decision vars: thrust u, fuel cost delta
183
- u = {}
184
- delta = {}
185
- for sat in satellites:
186
- for t in time_steps[:-1]:
187
- u[sat, t] = model.addVars(3, lb=-F_max, ub=F_max, name=f"u_{sat}_{t}")
188
- delta[sat, t] = model.addVar(lb=0.0, name=f"delta_{sat}_{t}")
189
- for k in range(3):
190
- abs_u = model.addVar(lb=0.0, name=f"abs_u_{sat}_{t}_{k}")
191
- model.addGenConstrAbs(abs_u, u[sat, t][k], name=f"absConstr_{sat}_{t}_{k}")
192
- model.addConstr(delta[sat, t] >= c_fuel * abs_u, name=f"fuelLink_{sat}_{t}_{k}")
193
-
194
- # dynamics and collect position vars
195
- initial_pos = {s: initial_state[s]["position"] for s in satellites}
196
- initial_vel = {s: initial_state[s]["velocity"] for s in satellites}
197
- positions = {}
198
- for sat in satellites:
199
- p, v = add_orbital_dynamics(model, sat, time_steps, dt, m_i, u, initial_pos, initial_vel)
200
- positions[sat] = p
201
-
202
- # collision avoidance as before
203
- for t in time_steps[1:]:
204
- for i, j in combinations(satellites, 2):
205
- dx = model.addVar(name=f"dx_{i}_{j}_{t}")
206
- dy = model.addVar(name=f"dy_{i}_{j}_{t}")
207
- dz = model.addVar(name=f"dz_{i}_{j}_{t}")
208
- model.addConstr(dx == positions[i][t][0] - positions[j][t][0], name=f"con_dx_{i}_{j}_{t}")
209
- model.addConstr(dy == positions[i][t][1] - positions[j][t][1], name=f"con_dy_{i}_{j}_{t}")
210
- model.addConstr(dz == positions[i][t][2] - positions[j][t][2], name=f"con_dz_{i}_{j}_{t}")
211
- absx = model.addVar(lb=0.0, name=f"absx_{i}_{j}_{t}")
212
- absy = model.addVar(lb=0.0, name=f"absy_{i}_{j}_{t}")
213
- absz = model.addVar(lb=0.0, name=f"absz_{i}_{j}_{t}")
214
- model.addGenConstrAbs(absx, dx, name=f"absxConstr_{i}_{j}_{t}")
215
- model.addGenConstrAbs(absy, dy, name=f"absyConstr_{i}_{j}_{t}")
216
- model.addGenConstrAbs(absz, dz, name=f"abszConstr_{i}_{j}_{t}")
217
- bx = model.addVar(vtype=GRB.BINARY, name=f"bx_{i}_{j}_{t}")
218
- by = model.addVar(vtype=GRB.BINARY, name=f"by_{i}_{j}_{t}")
219
- bz = model.addVar(vtype=GRB.BINARY, name=f"bz_{i}_{j}_{t}")
220
- model.addConstr(absx >= d_safe * bx, name=f"safe_x_{i}_{j}_{t}")
221
- model.addConstr(absy >= d_safe * by, name=f"safe_y_{i}_{j}_{t}")
222
- model.addConstr(absz >= d_safe * bz, name=f"safe_z_{i}_{j}_{t}")
223
- model.addConstr(bx + by + bz >= 1, name=f"sep_sum_{i}_{j}_{t}")
224
-
225
- # tracking penalty: absolute deviation from nominal
226
- tracking_dev = {}
227
- for sat in satellites:
228
- for t in time_steps:
229
- for coord in range(3):
230
- dev = model.addVar(lb=0.0, name=f"dev_{sat}_{t}_{coord}")
231
- tracking_dev[sat, t, coord] = dev
232
- # deviation = p - p_nom
233
- p_var = positions[sat][t][coord]
234
- p_nom = nominal_positions[sat][t][coord]
235
- # p_var - p_nom <= dev and -(p_var - p_nom) <= dev
236
- model.addConstr(p_var - p_nom <= dev, name=f"dev_pos_{sat}_{t}_{coord}")
237
- model.addConstr(p_nom - p_var <= dev, name=f"dev_neg_{sat}_{t}_{coord}")
238
-
239
- # objective: fuel + tracking deviation
240
- obj = gp.quicksum(delta[s, t] for s in satellites for t in time_steps[:-1]) \
241
- + gp.quicksum(tracking_dev[s, t, c] for s in satellites for t in time_steps for c in range(3))
242
- model.setObjective(obj, GRB.MINIMIZE)
243
-
244
- # solve
245
- model.optimize()
246
- if model.status == GRB.INFEASIBLE:
247
- model.computeIIS()
248
- model.write("infeasible.ilp")
249
- print("Model infeasible; IIS written to 'infeasible.ilp'.")
250
- else:
251
- print("Optimal solution found.")
252
- animate_satellite_trajectories(model, time_steps)
253
-
254
- if __name__ == "__main__":
255
- solve_lp_model()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
achref/src/models/infeasible.ilp DELETED
@@ -1,24 +0,0 @@
1
- \ Model SatelliteLP_WithTracking_copy
2
- \ LP format - for model browsing. Use MPS format to capture full model detail.
3
- Minimize
4
-
5
- Subject To
6
- init_p_Sat1_1: p_Sat1_0[1] = 0
7
- init_v_Sat1_1: v_Sat1_0[1] = 0
8
- dyn_p_Sat1_0_1: - 0.0125 u_Sat1_0[1] - p_Sat1_0[1] + p_Sat1_1[1]
9
- - 5 v_Sat1_0[1] = 0
10
- init_p_Sat2_1: p_Sat2_0[1] = 1.732051
11
- init_v_Sat2_1: v_Sat2_0[1] = -0.0866025
12
- dyn_p_Sat2_0_1: - 0.0125 u_Sat2_0[1] - p_Sat2_0[1] + p_Sat2_1[1]
13
- - 5 v_Sat2_0[1] = 0
14
- con_dy_Sat1_Sat2_1: - p_Sat1_1[1] + p_Sat2_1[1] + dy_Sat1_Sat2_1 = 0
15
- Bounds
16
- -infinity <= u_Sat1_0[1] <= 10
17
- u_Sat2_0[1] >= -10
18
- p_Sat1_0[1] free
19
- p_Sat1_1[1] free
20
- v_Sat1_0[1] free
21
- p_Sat2_0[1] free
22
- p_Sat2_1[1] free
23
- v_Sat2_0[1] free
24
- End
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
achref/src/plotting/plot_trajectories.py DELETED
@@ -1,104 +0,0 @@
1
- import matplotlib.pyplot as plt
2
- from mpl_toolkits.mplot3d import Axes3D
3
- import re
4
- import matplotlib.animation as animation
5
-
6
- def animate_satellite_trajectories(model, time_steps, interval=200):
7
- """
8
- Creates a 3D animated plot of satellite trajectories and thrust vectors.
9
-
10
- Args:
11
- model (gp.Model): Solved Gurobi model containing:
12
- - position variables named `p_{sat}_{t}[coord]`
13
- - thrust variables named `u_{sat}_{t}[coord]`
14
- time_steps (list of int): List of time step indices used in the optimization.
15
- interval (int): Delay between frames in milliseconds.
16
- """
17
- # Extract data containers
18
- positions = {}
19
- thrusts = {}
20
- # Patterns to match variable names
21
- p_pattern = re.compile(r'p_(\w+)_(\d+)\[(\d+)\]')
22
- u_pattern = re.compile(r'u_(\w+)_(\d+)\[(\d+)\]')
23
-
24
- # Populate positions and thrusts dictionaries
25
- for var in model.getVars():
26
- # Position variables
27
- pm = p_pattern.match(var.VarName)
28
- if pm:
29
- sat, t_str, coord_str = pm.groups()
30
- t = int(t_str)
31
- coord = int(coord_str)
32
- positions.setdefault(sat, {}).setdefault(t, [0.0, 0.0, 0.0])[coord] = var.Xn
33
- continue
34
- # Thrust variables
35
- um = u_pattern.match(var.VarName)
36
- if um:
37
- sat, t_str, coord_str = um.groups()
38
- t = int(t_str)
39
- coord = int(coord_str)
40
- thrusts.setdefault(sat, {}).setdefault(t, [0.0, 0.0, 0.0])[coord] = var.Xn
41
-
42
- sats = sorted(positions.keys())
43
-
44
- # Prepare 3D figure
45
- fig = plt.figure(figsize=(10, 8))
46
- ax = fig.add_subplot(111, projection='3d')
47
- ax.set_xlabel('X (m)')
48
- ax.set_ylabel('Y (m)')
49
- ax.set_zlabel('Z (m)')
50
- ax.set_title('Satellite Trajectories and Thrust Animation')
51
-
52
- # Plot objects for trajectories, end markers, and quivers
53
- traj_lines = {sat: ax.plot([], [], [], label=f'{sat} traj')[0] for sat in sats}
54
- end_markers = {sat: ax.plot([], [], [], marker='o', linestyle='')[0] for sat in sats}
55
- quiver_objs = {sat: None for sat in sats}
56
-
57
- # Set axis limits based on all positions
58
- all_x = [positions[s][t][0] for s in sats for t in positions[s]]
59
- all_y = [positions[s][t][1] for s in sats for t in positions[s]]
60
- all_z = [positions[s][t][2] for s in sats for t in positions[s]]
61
- ax.set_xlim(min(all_x), max(all_x))
62
- ax.set_ylim(min(all_y), max(all_y))
63
- ax.set_zlim(min(all_z), max(all_z))
64
- ax.legend()
65
-
66
- def update(frame):
67
- # Remove previous quivers
68
- for sat in sats:
69
- if quiver_objs[sat] is not None:
70
- quiver_objs[sat].remove()
71
-
72
- # Update each satellite's trajectory, marker, and thrust arrow
73
- for sat in sats:
74
- # Trajectory up to current frame
75
- xs = [positions[sat][t][0] for t in time_steps if t <= frame]
76
- ys = [positions[sat][t][1] for t in time_steps if t <= frame]
77
- zs = [positions[sat][t][2] for t in time_steps if t <= frame]
78
- traj_lines[sat].set_data(xs, ys)
79
- traj_lines[sat].set_3d_properties(zs)
80
-
81
- # End marker at last position
82
- if xs:
83
- end_markers[sat].set_data([xs[-1]], [ys[-1]])
84
- end_markers[sat].set_3d_properties([zs[-1]])
85
-
86
- # Thrust arrow at this frame
87
- ux, uy, uz = thrusts.get(sat, {}).get(frame, [0.0, 0.0, 0.0])
88
- # Draw new quiver
89
- quiver_objs[sat] = ax.quiver(
90
- xs[-1], ys[-1], zs[-1], ux, uy, uz,
91
- length=1e3, normalize=True
92
- )
93
-
94
- # Return artists to update
95
- artists = list(traj_lines.values()) + list(end_markers.values())
96
- artists += [q for q in quiver_objs.values() if q is not None]
97
- return artists
98
-
99
- # Create animation
100
- ani = animation.FuncAnimation(
101
- fig, update, frames=time_steps, interval=interval, blit=False
102
- )
103
- ani.save("satellite_trajectories.gif", writer='imagemagick', fps=5)
104
- plt.show()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -3,10 +3,10 @@ import os
3
  import gradio as gr
4
  import pandas as pd
5
 
6
- from models.gurobi_models import solve_plne, solve_refinery_optimization
7
- from ui.gradio_sections import oil_refinery_tab, project_info_tab, vehicle_routing_tab
8
 
9
- # Mock Data
10
  plne_df = pd.DataFrame(
11
  [
12
  {"Node": 0, "X": 50, "Y": 50, "Demand": 0},
@@ -18,91 +18,16 @@ plne_df = pd.DataFrame(
18
  ]
19
  )
20
 
21
- # Oil Refinery Optimization Mock Data
22
- crude_df = pd.DataFrame(
23
- [
24
- {"Crude": "Light Crude", "Cost": 60, "Availability": 10000},
25
- {"Crude": "Medium Crude", "Cost": 50, "Availability": 15000},
26
- {"Crude": "Heavy Crude", "Cost": 45, "Availability": 12000},
27
- ]
28
- )
29
-
30
- product_df = pd.DataFrame(
31
- [
32
- {"Product": "Premium Gasoline", "Price": 90, "Demand": 5000},
33
- {"Product": "Regular Gasoline", "Price": 80, "Demand": 7000},
34
- {"Product": "Diesel", "Price": 75, "Demand": 8000},
35
- ]
36
- )
37
-
38
- yields_df = pd.DataFrame(
39
- [
40
- # Light Crude yields
41
- {
42
- "Crude": "Light Crude",
43
- "Product": "Premium Gasoline",
44
- "Yield": 0.4,
45
- "Quality": 95,
46
- },
47
- {
48
- "Crude": "Light Crude",
49
- "Product": "Regular Gasoline",
50
- "Yield": 0.3,
51
- "Quality": 90,
52
- },
53
- {"Crude": "Light Crude", "Product": "Diesel", "Yield": 0.2, "Quality": 85},
54
- # Medium Crude yields
55
- {
56
- "Crude": "Medium Crude",
57
- "Product": "Premium Gasoline",
58
- "Yield": 0.3,
59
- "Quality": 85,
60
- },
61
- {
62
- "Crude": "Medium Crude",
63
- "Product": "Regular Gasoline",
64
- "Yield": 0.4,
65
- "Quality": 80,
66
- },
67
- {"Crude": "Medium Crude", "Product": "Diesel", "Yield": 0.3, "Quality": 80},
68
- # Heavy Crude yields
69
- {
70
- "Crude": "Heavy Crude",
71
- "Product": "Premium Gasoline",
72
- "Yield": 0.1,
73
- "Quality": 75,
74
- },
75
- {
76
- "Crude": "Heavy Crude",
77
- "Product": "Regular Gasoline",
78
- "Yield": 0.3,
79
- "Quality": 70,
80
- },
81
- {"Crude": "Heavy Crude", "Product": "Diesel", "Yield": 0.5, "Quality": 75},
82
- ]
83
- )
84
-
85
- quality_reqs_df = pd.DataFrame(
86
- [
87
- {"Product": "Premium Gasoline", "MinQuality": 90},
88
- {"Product": "Regular Gasoline", "MinQuality": 80},
89
- {"Product": "Diesel", "MinQuality": 75},
90
- ]
91
- )
92
-
93
  # Descriptions
94
  plne_description = """
95
  ### 🚚 Capacitated Vehicle Routing Problem
96
  Provide node coordinates and demands, plus vehicle capacity and number of vehicles.
97
  """
98
 
99
- refinery_description = """
100
- ### ⚙️ Oil Refinery Optimization Problem
101
 
102
- **Scenario**
103
- An oil refinery wants to determine the optimal production plan for different fuel products (like diesel, premium gasoline, and regular gasoline)
104
- using various crude oils. Each crude oil has different yields, costs, and qualities, and each product has its own demand,
105
- quality requirements, and selling price.
106
  """
107
 
108
  # Read and encode the PDF - go up one directory to find assets at project root
@@ -123,14 +48,7 @@ with gr.Blocks(title="Operations Research App") as ro_app:
123
  )
124
  with gr.Tabs():
125
  project_info_tab()
126
- oil_refinery_tab(
127
- crude_df,
128
- product_df,
129
- yields_df,
130
- quality_reqs_df,
131
- solve_refinery_optimization,
132
- refinery_description,
133
- )
134
  vehicle_routing_tab(plne_df, solve_plne, plne_description)
135
 
136
  if __name__ == "__main__":
 
3
  import gradio as gr
4
  import pandas as pd
5
 
6
+ from models.gurobi_models import solve_diet_problem, solve_plne
7
+ from ui.gradio_sections import diet_problem_tab, project_info_tab, vehicle_routing_tab
8
 
9
+ # Mock Data for Vehicle Routing Problem
10
  plne_df = pd.DataFrame(
11
  [
12
  {"Node": 0, "X": 50, "Y": 50, "Demand": 0},
 
18
  ]
19
  )
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  # Descriptions
22
  plne_description = """
23
  ### 🚚 Capacitated Vehicle Routing Problem
24
  Provide node coordinates and demands, plus vehicle capacity and number of vehicles.
25
  """
26
 
27
+ diet_description = """
28
+ ### 🍎 Diet Problem
29
 
30
+ Find the optimal diet that minimizes cost while meeting nutritional requirements.
 
 
 
31
  """
32
 
33
  # Read and encode the PDF - go up one directory to find assets at project root
 
48
  )
49
  with gr.Tabs():
50
  project_info_tab()
51
+ diet_problem_tab(solve_diet_problem, diet_description)
 
 
 
 
 
 
 
52
  vehicle_routing_tab(plne_df, solve_plne, plne_description)
53
 
54
  if __name__ == "__main__":
assets/compte_rendu.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:bf8d44f9e66c0cc7ad70d4875ccaa43ea0a489366c3626733d03d6b4259cdef6
3
- size 1506148
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:232255ba8666363b58f53a53eaed299529b2428a0863e647a42af720dcc50517
3
+ size 1218613
models/gurobi_models.py CHANGED
@@ -4,244 +4,486 @@ import matplotlib.pyplot as plt
4
  import pandas as pd
5
  from gurobipy import GRB, Model, quicksum
6
 
7
- import achref.src.logger as logger
8
 
9
  logger = logger.get_logger(__name__)
10
 
11
 
12
- def solve_refinery_optimization(
13
- crude_data, product_data, crude_product_yields, product_quality_reqs
14
- ):
15
  """
16
- Solves the Oil Refinery Optimization Problem.
17
 
18
  Args:
19
- crude_data (pd.DataFrame): DataFrame with columns ["Crude", "Cost", "Availability"]
20
- product_data (pd.DataFrame): DataFrame with columns ["Product", "Price", "Demand"]
21
- crude_product_yields (pd.DataFrame): DataFrame with columns ["Crude", "Product", "Yield", "Quality"]
22
- product_quality_reqs (pd.DataFrame): DataFrame with columns ["Product", "MinQuality"]
23
 
24
  Returns:
25
  tuple: (result_df, fig) where result_df contains the optimal solution and fig is a matplotlib figure
 
 
 
 
 
26
  """
27
- if not all(
28
- isinstance(df, pd.DataFrame)
29
- for df in [crude_data, product_data, crude_product_yields, product_quality_reqs]
30
- ):
31
- raise TypeError("All inputs must be pandas DataFrames")
32
-
33
- # Validate required columns
34
- if not set(["Crude", "Cost", "Availability"]).issubset(crude_data.columns):
35
- raise ValueError("crude_data must contain columns: Crude, Cost, Availability")
36
- if not set(["Product", "Price", "Demand"]).issubset(product_data.columns):
37
- raise ValueError("product_data must contain columns: Product, Price, Demand")
38
- if not set(["Crude", "Product", "Yield", "Quality"]).issubset(
39
- crude_product_yields.columns
40
- ):
41
- raise ValueError(
42
- "crude_product_yields must contain columns: Crude, Product, Yield, Quality"
43
- )
44
- if not set(["Product", "MinQuality"]).issubset(product_quality_reqs.columns):
45
- raise ValueError(
46
- "product_quality_reqs must contain columns: Product, MinQuality"
47
- )
48
 
49
- logger.info("Starting Gurobi model for Oil Refinery Optimization")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- # Create optimization model
52
- model = Model("OilRefineryOptimization")
53
- model.setParam("OutputFlag", 0)
 
 
 
 
54
 
55
- # Get unique crudes and products
56
- crudes = crude_data["Crude"].unique().tolist()
57
- products = product_data["Product"].unique().tolist()
 
 
 
58
 
59
- # Extract data
60
- costs = {row["Crude"]: row["Cost"] for _, row in crude_data.iterrows()}
61
- availability = {
62
- row["Crude"]: row["Availability"] for _, row in crude_data.iterrows()
63
- }
64
- prices = {row["Product"]: row["Price"] for _, row in product_data.iterrows()}
65
- demands = {row["Product"]: row["Demand"] for _, row in product_data.iterrows()}
66
- min_qualities = {
67
- row["Product"]: row["MinQuality"] for _, row in product_quality_reqs.iterrows()
68
- }
69
 
70
- # Create a dictionary for yields and qualities
71
- yields = {}
72
- qualities = {}
73
- for _, row in crude_product_yields.iterrows():
74
- crude, product = row["Crude"], row["Product"]
75
- yields[(crude, product)] = row["Yield"]
76
- qualities[(crude, product)] = row["Quality"]
77
-
78
- # Decision variables: amount of each crude oil used
79
- x = {crude: model.addVar(name=f"x_{crude}", lb=0) for crude in crudes}
80
-
81
- # Calculated variables: amount of each product produced from each crude
82
- prod_from_crude = {}
83
- for crude in crudes:
84
- for product in products:
85
- key = (crude, product)
86
- if key in yields:
87
- prod_from_crude[key] = yields[key] * x[crude]
88
-
89
- # Calculate total production per product
90
- total_production = {}
91
- for product in products:
92
- total_production[product] = quicksum(
93
- prod_from_crude.get((crude, product), 0) for crude in crudes
94
- )
95
 
96
- # Objective: Maximize profit (revenue - cost)
97
- revenue = quicksum(
98
- prices[product] * total_production[product] for product in products
99
- )
100
- cost = quicksum(costs[crude] * x[crude] for crude in crudes)
101
- model.setObjective(revenue - cost, GRB.MAXIMIZE)
102
 
103
- # Constraints:
 
 
 
104
 
105
- # 1. Crude availability constraints
106
- for crude in crudes:
107
- model.addConstr(x[crude] <= availability[crude], name=f"avail_{crude}")
 
 
 
 
108
 
109
- # 2. Demand satisfaction constraints
110
- for product in products:
111
- model.addConstr(
112
- total_production[product] >= demands[product], name=f"demand_{product}"
113
- )
 
 
 
114
 
115
- # 3. Quality constraints
116
- for product in products:
117
- if product in min_qualities:
118
- # Linearized quality constraint: sum(q_ij * y_ij * x_i) >= Q_j^min * sum(y_ij * x_i)
119
- quality_numerator = quicksum(
120
- qualities.get((crude, product), 0)
121
- * yields.get((crude, product), 0)
122
- * x[crude]
123
- for crude in crudes
124
- if (crude, product) in yields
 
 
 
125
  )
126
- model.addConstr(
127
- quality_numerator >= min_qualities[product] * total_production[product],
128
- name=f"quality_{product}",
 
 
 
 
129
  )
130
 
131
- # Solve the model
132
- model.optimize()
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- # Check if optimal solution was found
135
- if model.status != GRB.OPTIMAL:
136
- raise ValueError("Failed to find an optimal solution")
 
 
 
137
 
138
- # Extract results
139
- crude_results = pd.DataFrame(
140
- {
141
- "Crude": crudes,
142
- "Amount Used": [x[crude].X for crude in crudes],
143
- "Cost": [x[crude].X * costs[crude] for crude in crudes],
144
- }
145
- )
 
 
 
 
 
146
 
147
- # Calculate production results
148
- prod_results = []
149
- for product in products:
150
- prod_amount = sum(
151
- prod_from_crude.get((crude, product), 0).getValue()
152
- for crude in crudes
153
- if (crude, product) in prod_from_crude
154
- )
155
- revenue = prod_amount * prices[product]
156
- # Calculate average quality
157
- quality_numerator = sum(
158
- qualities.get((crude, product), 0)
159
- * prod_from_crude.get((crude, product), 0).getValue()
160
- for crude in crudes
161
- if (crude, product) in prod_from_crude
162
- )
163
- avg_quality = quality_numerator / prod_amount if prod_amount > 0 else 0
164
 
165
- prod_results.append(
166
- {
167
- "Product": product,
168
- "Amount Produced": prod_amount,
169
- "Revenue": revenue,
170
- "Average Quality": avg_quality,
171
- "Min Quality Required": min_qualities.get(product, "N/A"),
172
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  )
174
 
175
- product_results_df = pd.DataFrame(prod_results)
176
-
177
- # Create visualizations
178
- fig, axs = plt.subplots(2, 2, figsize=(14, 10))
179
- fig.suptitle("Oil Refinery Optimization Results", fontsize=16, y=1.02)
180
-
181
- # Plot 1: Crude Oil Usage
182
- axs[0, 0].bar(crude_results["Crude"], crude_results["Amount Used"])
183
- axs[0, 0].set_title("Crude Oil Usage")
184
- axs[0, 0].set_ylabel("Amount (Liters)")
185
- plt.setp(axs[0, 0].get_xticklabels(), rotation=45, ha="right")
186
-
187
- # Plot 2: Product Production
188
- axs[0, 1].bar(product_results_df["Product"], product_results_df["Amount Produced"])
189
- axs[0, 1].set_title("Product Production")
190
- axs[0, 1].set_ylabel("Amount (Liters)")
191
- plt.setp(axs[0, 1].get_xticklabels(), rotation=45, ha="right")
192
-
193
- # Plot 3: Profit/Cost Breakdown
194
- revenue_total = product_results_df["Revenue"].sum()
195
- cost_total = crude_results["Cost"].sum()
196
- profit = revenue_total - cost_total
197
- axs[1, 0].bar(
198
- ["Revenue", "Cost", "Profit"],
199
- [revenue_total, cost_total, profit],
200
- color=["green", "red", "blue"],
201
- )
202
- axs[1, 0].set_title("Financial Summary")
203
- axs[1, 0].set_ylabel("Amount ($)")
204
-
205
- # Plot 4: Quality vs Requirements
206
- products = product_results_df["Product"]
207
- achieved_quality = product_results_df["Average Quality"]
208
- required_quality = pd.to_numeric(
209
- product_results_df["Min Quality Required"], errors="coerce"
210
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
- x = range(len(products))
213
- width = 0.35
214
- axs[1, 1].bar(
215
- [i - width / 2 for i in x], achieved_quality, width, label="Achieved Quality"
216
- )
217
- axs[1, 1].bar(
218
- [i + width / 2 for i in x], required_quality, width, label="Required Quality"
219
- )
220
- axs[1, 1].set_title("Product Quality Analysis")
221
- axs[1, 1].set_xticks(x)
222
- axs[1, 1].set_xticklabels(products)
223
- axs[1, 1].set_ylabel("Quality")
224
- axs[1, 1].legend()
225
- plt.setp(axs[1, 1].get_xticklabels(), rotation=45, ha="right")
226
 
227
- fig.tight_layout()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- # Combine results for return
230
- combined_results = {
231
- "Crude Usage": crude_results,
232
- "Product Production": product_results_df,
233
- "Total Profit": profit,
234
- }
 
 
235
 
236
- # Convert to a results dataframe with all key information
237
- result_summary = pd.DataFrame(
238
- {
239
- "Category": ["Total Revenue", "Total Cost", "Total Profit"],
240
- "Value": [revenue_total, cost_total, profit],
241
- }
242
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
- return product_results_df, fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
 
247
  def _validate_plne_input(data, vehicle_capacity, num_vehicles):
@@ -553,4 +795,4 @@ def solve_plne(data: pd.DataFrame, vehicle_capacity: float, num_vehicles: int):
553
  except (ValueError, TypeError) as e:
554
  raise RuntimeError(f"Error in solve_plne: {e}")
555
  except Exception as e:
556
- raise RuntimeError(f"Unexpected error in solve_plne: {e}")
 
4
  import pandas as pd
5
  from gurobipy import GRB, Model, quicksum
6
 
7
+ import utils.logger as logger
8
 
9
  logger = logger.get_logger(__name__)
10
 
11
 
12
+ def solve_diet_problem(foods_df, requirements_df):
 
 
13
  """
14
+ Solves the Diet Problem using Linear Programming with flexible number of foods and nutrients.
15
 
16
  Args:
17
+ foods_df (pd.DataFrame): DataFrame with columns ['Food', 'Cost'] + nutrient columns
18
+ requirements_df (pd.DataFrame): DataFrame with columns ['Nutrient', 'Minimum']
 
 
19
 
20
  Returns:
21
  tuple: (result_df, fig) where result_df contains the optimal solution and fig is a matplotlib figure
22
+
23
+ Raises:
24
+ TypeError: If inputs are not DataFrames or contain invalid data types
25
+ ValueError: If data validation fails or optimization problem cannot be solved
26
+ Exception: If Gurobi solver encounters an error
27
  """
28
+ logger.info("Starting Gurobi model for Diet Problem")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
+ try:
31
+ # Type validation
32
+ if not isinstance(foods_df, pd.DataFrame):
33
+ raise TypeError("foods_df must be a pandas DataFrame")
34
+ if not isinstance(requirements_df, pd.DataFrame):
35
+ raise TypeError("requirements_df must be a pandas DataFrame")
36
+
37
+ # Empty data validation
38
+ if foods_df.empty:
39
+ raise ValueError(
40
+ "Foods data cannot be empty. Please provide at least one food item."
41
+ )
42
+ if requirements_df.empty:
43
+ raise ValueError(
44
+ "Requirements data cannot be empty. Please provide at least one nutritional requirement."
45
+ )
46
 
47
+ # Required columns validation
48
+ required_food_cols = {"Food", "Cost"}
49
+ if not required_food_cols.issubset(foods_df.columns):
50
+ missing = required_food_cols - set(foods_df.columns)
51
+ raise ValueError(
52
+ f"Missing required columns in foods data: {missing}. Required columns: {required_food_cols}"
53
+ )
54
 
55
+ required_req_cols = {"Nutrient", "Minimum"}
56
+ if not required_req_cols.issubset(requirements_df.columns):
57
+ missing = required_req_cols - set(requirements_df.columns)
58
+ raise ValueError(
59
+ f"Missing required columns in requirements data: {missing}. Required columns: {required_req_cols}"
60
+ )
61
 
62
+ # Duplicate validation
63
+ if foods_df["Food"].duplicated().any():
64
+ duplicates = foods_df[foods_df["Food"].duplicated()]["Food"].tolist()
65
+ raise ValueError(
66
+ f"Duplicate food names found: {duplicates}. Each food must have a unique name."
67
+ )
 
 
 
 
68
 
69
+ if requirements_df["Nutrient"].duplicated().any():
70
+ duplicates = requirements_df[requirements_df["Nutrient"].duplicated()][
71
+ "Nutrient"
72
+ ].tolist()
73
+ raise ValueError(
74
+ f"Duplicate nutrient names found: {duplicates}. Each nutrient must be unique."
75
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
+ # Get nutrient columns (all columns except 'Food' and 'Cost')
78
+ nutrient_cols = [col for col in foods_df.columns if col not in ["Food", "Cost"]]
 
 
 
 
79
 
80
+ if not nutrient_cols:
81
+ raise ValueError(
82
+ "No nutrient columns found in foods data. Please include at least one nutrient column (e.g., 'Protein', 'Fat', 'Carbs')."
83
+ )
84
 
85
+ # Data type validation
86
+ try:
87
+ foods_df["Cost"] = pd.to_numeric(foods_df["Cost"], errors="raise")
88
+ except (ValueError, TypeError) as e:
89
+ raise ValueError(
90
+ f"Cost column contains non-numeric values. All costs must be numbers. Error: {str(e)}"
91
+ )
92
 
93
+ try:
94
+ requirements_df["Minimum"] = pd.to_numeric(
95
+ requirements_df["Minimum"], errors="raise"
96
+ )
97
+ except (ValueError, TypeError) as e:
98
+ raise ValueError(
99
+ f"Minimum column contains non-numeric values. All requirements must be numbers. Error: {str(e)}"
100
+ )
101
 
102
+ for col in nutrient_cols:
103
+ try:
104
+ foods_df[col] = pd.to_numeric(foods_df[col], errors="raise")
105
+ except (ValueError, TypeError) as e:
106
+ raise ValueError(
107
+ f"Nutrient column '{col}' contains non-numeric values. All nutrient values must be numbers. Error: {str(e)}"
108
+ )
109
+
110
+ # Value range validation
111
+ if (foods_df["Cost"] < 0).any():
112
+ negative_costs = foods_df[foods_df["Cost"] < 0]["Food"].tolist()
113
+ raise ValueError(
114
+ f"Negative costs found for foods: {negative_costs}. All costs must be non-negative."
115
  )
116
+
117
+ if (requirements_df["Minimum"] <= 0).any():
118
+ non_positive_reqs = requirements_df[requirements_df["Minimum"] <= 0][
119
+ "Nutrient"
120
+ ].tolist()
121
+ raise ValueError(
122
+ f"Non-positive requirements found for nutrients: {non_positive_reqs}. All requirements must be positive."
123
  )
124
 
125
+ for col in nutrient_cols:
126
+ if (foods_df[col] < 0).any():
127
+ negative_nutrients = foods_df[foods_df[col] < 0]["Food"].tolist()
128
+ raise ValueError(
129
+ f"Negative {col} values found for foods: {negative_nutrients}. All nutrient values must be non-negative."
130
+ )
131
+
132
+ # Check if any required nutrient is missing from foods data
133
+ missing_nutrients = set(requirements_df["Nutrient"]) - set(nutrient_cols)
134
+ if missing_nutrients:
135
+ raise ValueError(
136
+ f"Required nutrients missing from foods data: {missing_nutrients}. Please add these nutrient columns to your foods data."
137
+ )
138
 
139
+ # Check for zero costs (potential unbounded solution)
140
+ if (foods_df["Cost"] == 0).any():
141
+ zero_cost_foods = foods_df[foods_df["Cost"] == 0]["Food"].tolist()
142
+ logger.warning(
143
+ f"Foods with zero cost detected: {zero_cost_foods}. This may lead to unrealistic solutions."
144
+ )
145
 
146
+ # Feasibility pre-check: ensure at least one food provides each required nutrient
147
+ for _, req_row in requirements_df.iterrows():
148
+ nutrient = req_row["Nutrient"]
149
+ if nutrient in nutrient_cols:
150
+ max_nutrient = foods_df[nutrient].max()
151
+ if max_nutrient == 0:
152
+ raise ValueError(
153
+ f"No food provides the required nutrient '{nutrient}'. Problem is infeasible."
154
+ )
155
+ if max_nutrient < req_row["Minimum"]:
156
+ logger.warning(
157
+ f"Maximum {nutrient} content ({max_nutrient}) is less than requirement ({req_row['Minimum']}). May need multiple foods."
158
+ )
159
 
160
+ except Exception as e:
161
+ logger.error(f"Data validation failed: {str(e)}")
162
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
+ # Create optimization model
165
+ try:
166
+ model = Model("DietProblem")
167
+ model.setParam("OutputFlag", 0)
168
+
169
+ # Set time limit to prevent infinite solving (60 seconds)
170
+ model.setParam("TimeLimit", 60)
171
+
172
+ logger.info("Creating decision variables...")
173
+
174
+ # Decision variables: units of each food
175
+ food_vars = {}
176
+ for i, food_name in enumerate(foods_df["Food"]):
177
+ if pd.isna(food_name) or str(food_name).strip() == "":
178
+ raise ValueError(
179
+ f"Empty or invalid food name at row {i}. All foods must have valid names."
180
+ )
181
+ food_vars[food_name] = model.addVar(name=f"Food_{i}_{food_name}", lb=0)
182
+
183
+ logger.info("Setting objective function...")
184
+
185
+ # Objective: Minimize total cost
186
+ model.setObjective(
187
+ quicksum(
188
+ foods_df.loc[foods_df["Food"] == food, "Cost"].iloc[0] * var
189
+ for food, var in food_vars.items()
190
+ ),
191
+ GRB.MINIMIZE,
192
  )
193
 
194
+ logger.info("Adding constraints...")
195
+
196
+ # Constraints: Nutritional requirements
197
+ for _, req_row in requirements_df.iterrows():
198
+ nutrient = req_row["Nutrient"]
199
+ min_requirement = req_row["Minimum"]
200
+
201
+ if pd.isna(nutrient) or str(nutrient).strip() == "":
202
+ raise ValueError(
203
+ "Empty or invalid nutrient name found. All nutrients must have valid names."
204
+ )
205
+
206
+ if nutrient in nutrient_cols:
207
+ model.addConstr(
208
+ quicksum(
209
+ foods_df.loc[foods_df["Food"] == food, nutrient].iloc[0] * var
210
+ for food, var in food_vars.items()
211
+ )
212
+ >= min_requirement,
213
+ name=f"{nutrient}_requirement",
214
+ )
215
+ else:
216
+ logger.warning(
217
+ f"Nutrient '{nutrient}' not found in foods data. Skipping constraint."
218
+ )
219
+
220
+ logger.info("Solving optimization model...")
221
+
222
+ # Solve the model
223
+ model.optimize()
224
+
225
+ # Enhanced status checking
226
+ if model.status == GRB.OPTIMAL:
227
+ logger.info("Optimal solution found successfully")
228
+ elif model.status == GRB.INFEASIBLE:
229
+ logger.error("Problem is infeasible")
230
+ # Try to compute IIS (Irreducible Inconsistent Subsystem) for debugging
231
+ try:
232
+ model.computeIIS()
233
+ iis_constraints = []
234
+ for constr in model.getConstrs():
235
+ if constr.IISConstr:
236
+ iis_constraints.append(constr.ConstrName)
237
+ if iis_constraints:
238
+ raise ValueError(
239
+ f"Problem is infeasible. Conflicting constraints: {iis_constraints}. Try reducing requirements or adding more diverse foods."
240
+ )
241
+ else:
242
+ raise ValueError(
243
+ "Problem is infeasible. The nutritional requirements cannot be met with the provided foods. Try reducing requirements or adding more foods with different nutritional profiles."
244
+ )
245
+ except Exception as iis_error:
246
+ logger.warning(f"Could not compute IIS: {str(iis_error)}")
247
+ raise ValueError(
248
+ "Problem is infeasible. The nutritional requirements cannot be met with the provided foods. Try reducing requirements or adding more diverse foods."
249
+ )
250
+ elif model.status == GRB.UNBOUNDED:
251
+ logger.error("Problem is unbounded")
252
+ raise ValueError(
253
+ "Problem is unbounded. This usually occurs when some foods have zero cost. Please ensure all foods have positive costs."
254
+ )
255
+ elif model.status == GRB.TIME_LIMIT:
256
+ logger.error("Time limit reached")
257
+ raise ValueError(
258
+ "Solver time limit reached (60 seconds). The problem may be too complex. Try simplifying the problem or contact support."
259
+ )
260
+ elif model.status == GRB.INTERRUPTED:
261
+ logger.error("Solver was interrupted")
262
+ raise ValueError("Solver was interrupted. Please try again.")
263
+ elif model.status == GRB.NUMERIC:
264
+ logger.error("Numerical difficulties encountered")
265
+ raise ValueError(
266
+ "Numerical difficulties encountered. Try using simpler numbers or scaling your data."
267
+ )
268
+ else:
269
+ logger.error(f"Unexpected solver status: {model.status}")
270
+ raise ValueError(
271
+ f"Failed to find an optimal solution. Solver status: {model.status}. Please check your input data."
272
+ )
273
 
274
+ except Exception as gurobi_error:
275
+ logger.error(f"Gurobi optimization error: {str(gurobi_error)}")
276
+ if "Gurobi" in str(type(gurobi_error)):
277
+ raise ValueError(
278
+ f"Gurobi solver error: {str(gurobi_error)}. Please check your Gurobi installation and license."
279
+ )
280
+ else:
281
+ raise
 
 
 
 
 
 
282
 
283
+ # Extract results
284
+ try:
285
+ total_cost = model.objVal
286
+ logger.info(f"Optimal solution found with total cost: {total_cost:.2f}")
287
+
288
+ # Create results dataframe
289
+ result_rows = []
290
+ for food_name, var in food_vars.items():
291
+ try:
292
+ food_row = foods_df[foods_df["Food"] == food_name].iloc[0]
293
+ amount = var.X
294
+
295
+ result_row = {
296
+ "Food": food_name,
297
+ "Units": amount,
298
+ "Cost": food_row["Cost"] * amount,
299
+ }
300
+
301
+ # Add nutrient contributions
302
+ for nutrient in nutrient_cols:
303
+ result_row[nutrient] = food_row[nutrient] * amount
304
+
305
+ result_rows.append(result_row)
306
+ except Exception as extract_error:
307
+ logger.error(
308
+ f"Error extracting results for food '{food_name}': {str(extract_error)}"
309
+ )
310
+ raise ValueError(
311
+ f"Error processing results for food '{food_name}': {str(extract_error)}"
312
+ )
313
+
314
+ result_df = pd.DataFrame(result_rows)
315
+
316
+ # Validate results
317
+ if result_df.empty:
318
+ raise ValueError(
319
+ "No results generated. This is unexpected after finding an optimal solution."
320
+ )
321
 
322
+ # Check if any solution violates non-negativity (shouldn't happen, but good to check)
323
+ if (result_df["Units"] < -1e-6).any():
324
+ negative_foods = result_df[result_df["Units"] < -1e-6]["Food"].tolist()
325
+ logger.warning(
326
+ f"Negative amounts found for foods (numerical error): {negative_foods}"
327
+ )
328
+ # Clamp negative values to zero
329
+ result_df["Units"] = result_df["Units"].clip(lower=0)
330
 
331
+ except Exception as result_error:
332
+ logger.error(f"Error extracting optimization results: {str(result_error)}")
333
+ raise ValueError(
334
+ f"Failed to extract optimization results: {str(result_error)}"
335
+ ) # Create flexible visualizations
336
+ try:
337
+ fig, axs = plt.subplots(2, 2, figsize=(14, 10))
338
+ fig.suptitle("Diet Problem Optimization Results", fontsize=16, y=1.02)
339
+
340
+ # Plot 1: Food Units - only show foods with positive amounts
341
+ foods_with_amounts = result_df[result_df["Units"] > 0.001]
342
+ if not foods_with_amounts.empty:
343
+ colors = plt.cm.Set3(range(len(foods_with_amounts)))
344
+ axs[0, 0].bar(
345
+ foods_with_amounts["Food"], foods_with_amounts["Units"], color=colors
346
+ )
347
+ axs[0, 0].set_title("Optimal Food Quantities")
348
+ axs[0, 0].set_ylabel("Units")
349
+ axs[0, 0].tick_params(axis="x", rotation=45)
350
+ else:
351
+ axs[0, 0].text(
352
+ 0.5,
353
+ 0.5,
354
+ "No foods selected\n(all amounts are zero)",
355
+ ha="center",
356
+ va="center",
357
+ transform=axs[0, 0].transAxes,
358
+ )
359
+ axs[0, 0].set_title("Optimal Food Quantities")
360
 
361
+ # Plot 2: Cost Breakdown
362
+ if not foods_with_amounts.empty:
363
+ axs[0, 1].bar(
364
+ foods_with_amounts["Food"], foods_with_amounts["Cost"], color=colors
365
+ )
366
+ axs[0, 1].set_title("Cost per Food Type")
367
+ axs[0, 1].set_ylabel("Cost ($)")
368
+ axs[0, 1].tick_params(axis="x", rotation=45)
369
+ else:
370
+ axs[0, 1].text(
371
+ 0.5,
372
+ 0.5,
373
+ "No costs to display",
374
+ ha="center",
375
+ va="center",
376
+ transform=axs[0, 1].transAxes,
377
+ )
378
+ axs[0, 1].set_title("Cost per Food Type")
379
+
380
+ # Plot 3: Nutritional Requirements vs Achieved
381
+ achieved_nutrients = {}
382
+ for nutrient in nutrient_cols:
383
+ achieved_nutrients[nutrient] = result_df[nutrient].sum()
384
+
385
+ requirements_dict = dict(
386
+ zip(requirements_df["Nutrient"], requirements_df["Minimum"])
387
+ )
388
+
389
+ nutrients = list(nutrient_cols)
390
+ requirements = [requirements_dict.get(nut, 0) for nut in nutrients]
391
+ achieved = [achieved_nutrients.get(nut, 0) for nut in nutrients]
392
+
393
+ if nutrients:
394
+ x_pos = range(len(nutrients))
395
+ width = 0.35
396
+ axs[1, 0].bar(
397
+ [i - width / 2 for i in x_pos],
398
+ requirements,
399
+ width,
400
+ label="Required",
401
+ color="red",
402
+ alpha=0.7,
403
+ )
404
+ axs[1, 0].bar(
405
+ [i + width / 2 for i in x_pos],
406
+ achieved,
407
+ width,
408
+ label="Achieved",
409
+ color="green",
410
+ alpha=0.7,
411
+ )
412
+ axs[1, 0].set_title("Nutritional Requirements vs Achieved")
413
+ axs[1, 0].set_ylabel("Units")
414
+ axs[1, 0].set_xticks(x_pos)
415
+ axs[1, 0].set_xticklabels(nutrients, rotation=45)
416
+ axs[1, 0].legend()
417
+ else:
418
+ axs[1, 0].text(
419
+ 0.5,
420
+ 0.5,
421
+ "No nutrients to display",
422
+ ha="center",
423
+ va="center",
424
+ transform=axs[1, 0].transAxes,
425
+ )
426
+ axs[1, 0].set_title("Nutritional Requirements vs Achieved")
427
+
428
+ # Plot 4: Summary Information
429
+ summary_data = [("Total Cost", total_cost)]
430
+ for nutrient in nutrient_cols:
431
+ summary_data.append(
432
+ (f"Total {nutrient}", achieved_nutrients.get(nutrient, 0))
433
+ )
434
+
435
+ if summary_data:
436
+ summary_labels, summary_values = zip(*summary_data)
437
+ colors_summary = plt.cm.viridis(range(len(summary_data)))
438
+
439
+ bars = axs[1, 1].bar(summary_labels, summary_values, color=colors_summary)
440
+ axs[1, 1].set_title("Diet Summary")
441
+ axs[1, 1].set_ylabel("Value")
442
+
443
+ # Add value labels on bars
444
+ for bar, value in zip(bars, summary_values):
445
+ height = bar.get_height()
446
+ axs[1, 1].text(
447
+ bar.get_x() + bar.get_width() / 2.0,
448
+ height + max(summary_values) * 0.01,
449
+ f"{value:.2f}",
450
+ ha="center",
451
+ va="bottom",
452
+ fontsize=8,
453
+ )
454
+ axs[1, 1].tick_params(axis="x", rotation=45)
455
+ else:
456
+ axs[1, 1].text(
457
+ 0.5,
458
+ 0.5,
459
+ "No summary data to display",
460
+ ha="center",
461
+ va="center",
462
+ transform=axs[1, 1].transAxes,
463
+ )
464
+ axs[1, 1].set_title("Diet Summary")
465
+
466
+ fig.tight_layout()
467
+
468
+ logger.info("Visualization created successfully")
469
+
470
+ except Exception as plot_error:
471
+ logger.error(f"Error creating visualization: {str(plot_error)}")
472
+ # Create a simple fallback plot
473
+ fig, ax = plt.subplots(1, 1, figsize=(8, 6))
474
+ ax.text(
475
+ 0.5,
476
+ 0.5,
477
+ f"Visualization Error\nOptimal Cost: ${total_cost:.2f}\nSee results table for details",
478
+ ha="center",
479
+ va="center",
480
+ transform=ax.transAxes,
481
+ fontsize=12,
482
+ )
483
+ ax.set_title("Diet Problem Results")
484
+ ax.axis("off")
485
+
486
+ return result_df, fig
487
 
488
 
489
  def _validate_plne_input(data, vehicle_capacity, num_vehicles):
 
795
  except (ValueError, TypeError) as e:
796
  raise RuntimeError(f"Error in solve_plne: {e}")
797
  except Exception as e:
798
+ raise RuntimeError(f"Unexpected error in solve_plne: {e}")
requirements.txt CHANGED
@@ -7,6 +7,7 @@ charset-normalizer==3.4.2
7
  click==8.1.8
8
  contourpy==1.3.2
9
  cycler==0.12.1
 
10
  fastapi==0.115.12
11
  ffmpy==0.5.0
12
  filelock==3.18.0
@@ -41,6 +42,7 @@ pydub==0.25.1
41
  Pygments==2.19.1
42
  pyparsing==3.2.3
43
  python-dateutil==2.9.0.post0
 
44
  python-multipart==0.0.20
45
  pytz==2025.2
46
  PyYAML==6.0.2
 
7
  click==8.1.8
8
  contourpy==1.3.2
9
  cycler==0.12.1
10
+ dotenv==0.9.9
11
  fastapi==0.115.12
12
  ffmpy==0.5.0
13
  filelock==3.18.0
 
42
  Pygments==2.19.1
43
  pyparsing==3.2.3
44
  python-dateutil==2.9.0.post0
45
+ python-dotenv==1.1.0
46
  python-multipart==0.0.20
47
  pytz==2025.2
48
  PyYAML==6.0.2
ui/gradio_sections.py CHANGED
@@ -4,7 +4,7 @@ import os
4
  import gradio as gr
5
  import pandas as pd
6
 
7
- import achref.src.logger as logger
8
 
9
  logger = logger.get_logger(__name__)
10
 
@@ -13,21 +13,68 @@ def project_info_tab():
13
  with gr.Tab("\U0001f4d8 Project Info"):
14
  gr.Markdown(
15
  """
16
- # \U0001f393 GL3 - 2025 - Operational Research Project
17
- This application demonstrates how **Linear Programming (PL)** and **Mixed-Integer Linear Programming (PLNE)** can be applied to solve real-world optimisation problems using **Gurobi**.
18
-
19
- ---
20
- # \U0001f465 Project Members
21
- - **Kacem Mathlouthi** — GL3/2
22
- - **Mohamed Amine Houas** — GL3/1
23
- - **Oussema Kraiem** — GL3/2
24
- - **Yassine Taieb** — GL3/2
25
- - **Youssef Sghairi** — GL3/2
26
- - **Youssef Aaridhi** — GL3/2
27
- - **Achref Ben Ammar** — GL3/1
28
- ---
29
- # \U0001f9fe Compte Rendu
30
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  )
32
  pdf_path = os.path.join(
33
  os.path.dirname(os.path.dirname(__file__)), "assets", "compte_rendu.pdf"
@@ -43,116 +90,323 @@ def project_info_tab():
43
  )
44
 
45
 
46
- def oil_refinery_tab(
47
- mock_crude_data,
48
- mock_product_data,
49
- mock_yields_data,
50
- mock_quality_reqs,
51
- solve_refinery_optimization,
52
- refinery_description,
53
- ):
54
- with gr.Tab("\U0001f3ed Oil Refinery Optimization (PL)"):
55
- gr.Markdown(refinery_description)
56
 
57
  # Add mathematical model description
58
  gr.Markdown(
59
  r"""
60
  ### 🧮 Mathematical Formulation
61
 
62
- **Sets and Indices**
63
- | Symbol | Description |
64
- |-------------|-----------------------------------------|
65
- | $$i=1,...,m$$ | Types of crude oil (inputs) |
66
- | $$j=1,...,n$$ | Types of fuel products (outputs) |
67
-
68
  **Parameters**
69
- | Symbol | Description |
70
- |-------------|-----------------------------------------|
71
- | $$c_i$$ | Cost per unit of crude $i$ |
72
- | $$p_j$$ | Selling price per unit of product $j$ |
73
- | $$y_{ij}$$ | Yield of product $j$ from crude $i$ (liters of product per liter of crude) |
74
- | $$q_{ij}$$ | Quality contribution of crude $i$ to product $j$ |
75
- | $$Q_j^{min}$$ | Minimum average quality required for product $j$ |
76
- | $$D_j$$ | Minimum demand (liters) for product $j$ |
77
- | $$A_i$$ | Availability limit (liters) of crude $i$ |
78
 
79
  **Decision Variables**
80
  | Symbol | Description |
81
  |-------------|-----------------------------------------|
82
- | $$x_i$$ | Amount of crude oil $i$ used (liters) |
83
 
84
- **Objective:**
85
  $$
86
- \text{Maximize} \quad \left(\sum_{j=1}^n p_j \cdot \sum_{i=1}^m y_{ij} \cdot x_i - \sum_{i=1}^m c_i \cdot x_i\right)
87
  $$
88
 
89
  **Constraints:**
90
- 1. Crude Availability:
91
- $$x_i \leq A_i \quad \forall i$$
92
-
93
- 2. Demand Satisfaction:
94
- $$\sum_{i=1}^m y_{ij} \cdot x_i \geq D_j \quad \forall j$$
95
-
96
- 3. Quality Requirements:
97
- $$\sum_{i=1}^m q_{ij} \cdot y_{ij} \cdot x_i \geq Q_j^{min} \cdot \sum_{i=1}^m y_{ij} \cdot x_i \quad \forall j$$
98
 
99
- 4. Non-negativity:
100
- $$x_i \geq 0 \quad \forall i$$
101
  """
102
  )
103
 
104
- with gr.Tabs():
105
- with gr.TabItem("Crude Oils"):
106
- crude_input = gr.Dataframe(
107
- headers=["Crude", "Cost", "Availability"],
108
- value=mock_crude_data,
109
- label="Crude Oil Data",
 
 
 
 
 
 
 
110
  )
111
 
112
- with gr.TabItem("Products"):
113
- product_input = gr.Dataframe(
114
- headers=["Product", "Price", "Demand"],
115
- value=mock_product_data,
116
- label="Product Data",
 
 
 
117
  )
118
 
119
- with gr.TabItem("Yields & Quality"):
120
- yields_input = gr.Dataframe(
121
- headers=["Crude", "Product", "Yield", "Quality"],
122
- value=mock_yields_data,
123
- label="Yield & Quality Data",
 
 
 
 
 
124
  )
125
 
126
- with gr.TabItem("Quality Requirements"):
127
- quality_reqs_input = gr.Dataframe(
128
- headers=["Product", "MinQuality"],
129
- value=mock_quality_reqs,
130
- label="Quality Requirements",
 
 
 
131
  )
132
 
133
- solve_btn = gr.Button("Solve Refinery Optimization Problem")
134
  status_output = gr.Textbox(label="Status", interactive=False)
135
  results_table = gr.Dataframe(label="Optimization Results")
136
  results_plot = gr.Plot(label="Results Visualization")
137
 
138
- def _solve_refinery_problem(crude_df, product_df, yields_df, quality_df):
139
  try:
140
- # Convert all numeric columns to float
141
- for df in [crude_df, product_df, yields_df, quality_df]:
142
- for col in df.columns:
143
- if col not in ["Crude", "Product"]:
144
- df[col] = pd.to_numeric(df[col], errors="coerce")
145
-
146
- result_df, fig = solve_refinery_optimization(
147
- crude_df, product_df, yields_df, quality_df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  )
149
- return result_df, fig, "Solved Successfully"
 
 
 
 
 
 
150
  except Exception as e:
151
- return pd.DataFrame(), None, f" Error: {str(e)}"
 
 
 
 
 
 
 
 
 
152
 
153
  solve_btn.click(
154
- fn=_solve_refinery_problem,
155
- inputs=[crude_input, product_input, yields_input, quality_reqs_input],
156
  outputs=[results_table, results_plot, status_output],
157
  )
158
 
@@ -264,4 +518,4 @@ def vehicle_routing_tab(mock_plne_df, solve_plne, plne_description):
264
  fn=_solve_vrp_with_floats,
265
  inputs=[vrp_input, cap_input, k_input],
266
  outputs=[result_table, result_plot, status_output],
267
- )
 
4
  import gradio as gr
5
  import pandas as pd
6
 
7
+ import utils.logger as logger
8
 
9
  logger = logger.get_logger(__name__)
10
 
 
13
  with gr.Tab("\U0001f4d8 Project Info"):
14
  gr.Markdown(
15
  """
16
+ # \U0001f393 GL3 - 2025 - Operational Research Project
17
+ This application demonstrates how **Linear Programming (PL)** and **Mixed-Integer Linear Programming (PLNE)** can be applied to solve real-world optimisation problems using **Gurobi**.
18
+ """
19
+ )
20
+
21
+ gr.HTML(
22
+ """
23
+ <style>
24
+ .member-card {
25
+ display: flex;
26
+ flex-direction: column;
27
+ align-items: center;
28
+ width: 160px;
29
+ text-align: center;
30
+ margin: 10px;
31
+ }
32
+ .member-card img {
33
+ width: 120px;
34
+ height: 140px;
35
+ object-fit: cover;
36
+ border-radius: 8px;
37
+ border: 1px solid #ccc;
38
+ }
39
+ .member-name {
40
+ margin-top: 8px;
41
+ font-weight: bold;
42
+ }
43
+ </style>
44
+ <div style="display: flex; flex-wrap: wrap; justify-content: center;">
45
+ <div class="member-card">
46
+ <img src="https://raw.githubusercontent.com/KacemMathlouthi/OperationsResearch/main/assets/images/kacem_mathlouthi.jpg" alt="Kacem Mathlouthi">
47
+ <div class="member-name">Kacem Mathlouthi</div>
48
+ </div>
49
+ <div class="member-card">
50
+ <img src="https://raw.githubusercontent.com/KacemMathlouthi/OperationsResearch/main/assets/images/mohamed_amine_haouas.jpg" alt="Mohamed Amine Houas">
51
+ <div class="member-name">Mohamed Amine Houas</div>
52
+ </div>
53
+ <div class="member-card">
54
+ <img src="https://raw.githubusercontent.com/KacemMathlouthi/OperationsResearch/main/assets/images/oussema_kraiem.jpg" alt="Oussema Kraiem">
55
+ <div class="member-name">Oussema Kraiem</div>
56
+ </div>
57
+ <div class="member-card">
58
+ <img src="https://raw.githubusercontent.com/KacemMathlouthi/OperationsResearch/main/assets/images/mohamed_yassine_taieb.jpg" alt="Yassine Taieb">
59
+ <div class="member-name">Yassine Taieb</div>
60
+ </div>
61
+ <div class="member-card">
62
+ <img src="https://raw.githubusercontent.com/KacemMathlouthi/OperationsResearch/main/assets/images/youssef_sghairi.jpg" alt="Youssef Sghairi">
63
+ <div class="member-name">Youssef Sghairi</div>
64
+ </div>
65
+ <div class="member-card">
66
+ <img src="https://raw.githubusercontent.com/KacemMathlouthi/OperationsResearch/main/assets/images/youssef_aridhi.jpg" alt="Youssef Aaridhi">
67
+ <div class="member-name">Youssef Aaridhi</div>
68
+ </div>
69
+ </div>
70
+ """
71
+ )
72
+
73
+ gr.Markdown(
74
+ """
75
+ ---
76
+ # \U0001f9fe Compte Rendu
77
+ """
78
  )
79
  pdf_path = os.path.join(
80
  os.path.dirname(os.path.dirname(__file__)), "assets", "compte_rendu.pdf"
 
90
  )
91
 
92
 
93
+ def diet_problem_tab(solve_diet_problem, diet_description):
94
+ with gr.Tab("🍎 Diet Problem (PL)"):
95
+ gr.Markdown(diet_description)
 
 
 
 
 
 
 
96
 
97
  # Add mathematical model description
98
  gr.Markdown(
99
  r"""
100
  ### 🧮 Mathematical Formulation
101
 
 
 
 
 
 
 
102
  **Parameters**
103
+ | Symbol | Description |
104
+ |-------------|------------------------------------------------|
105
+ | $$I$$ | Set of available foods |
106
+ | $$J$$ | Set of nutrients |
107
+ | $$c_i$$ | Cost per unit of food i |
108
+ | $$n_{ij}$$ | Amount of nutrient j in one unit of food i |
109
+ | $$R_j$$ | Minimum requirement for nutrient j |
 
 
110
 
111
  **Decision Variables**
112
  | Symbol | Description |
113
  |-------------|-----------------------------------------|
114
+ | $$x_i$$ | Units of food i to consume |
115
 
116
+ **Objective Function:**
117
  $$
118
+ \text{Minimize} \quad Z = \sum_{i \in I} c_i \cdot x_i
119
  $$
120
 
121
  **Constraints:**
122
+ 1. **Nutritional requirements:**
123
+ $$\sum_{i \in I} n_{ij} \cdot x_i \geq R_j \quad \forall j \in J$$
 
 
 
 
 
 
124
 
125
+ 2. **Non-negativity:**
126
+ $$x_i \geq 0 \quad \forall i \in I$$
127
  """
128
  )
129
 
130
+ with gr.Row():
131
+ with gr.Column():
132
+ gr.Markdown("### 🍎 Foods Data")
133
+ gr.Markdown(
134
+ "Add foods with their costs and nutritional content per unit:"
135
+ )
136
+
137
+ # Default foods data
138
+ default_foods = pd.DataFrame(
139
+ [
140
+ {"Food": "Food A", "Cost": 3.0, "Protein": 2.0, "Fat": 1.0},
141
+ {"Food": "Food B", "Cost": 2.0, "Protein": 1.0, "Fat": 2.0},
142
+ ]
143
  )
144
 
145
+ foods_input = gr.Dataframe(
146
+ value=default_foods,
147
+ headers=["Food", "Cost", "Protein", "Fat"],
148
+ datatype=["str", "number", "number", "number"],
149
+ col_count=(4, "dynamic"),
150
+ row_count=(2, "dynamic"),
151
+ label="Foods and Nutritional Content",
152
+ interactive=True,
153
  )
154
 
155
+ with gr.Column():
156
+ gr.Markdown("### 🥗 Nutritional Requirements")
157
+ gr.Markdown("Specify minimum daily requirements for each nutrient:")
158
+
159
+ # Default requirements data
160
+ default_requirements = pd.DataFrame(
161
+ [
162
+ {"Nutrient": "Protein", "Minimum": 8.0},
163
+ {"Nutrient": "Fat", "Minimum": 6.0},
164
+ ]
165
  )
166
 
167
+ requirements_input = gr.Dataframe(
168
+ value=default_requirements,
169
+ headers=["Nutrient", "Minimum"],
170
+ datatype=["str", "number"],
171
+ col_count=(2, "fixed"),
172
+ row_count=(2, "dynamic"),
173
+ label="Nutritional Requirements",
174
+ interactive=True,
175
  )
176
 
177
+ solve_btn = gr.Button("Solve Diet Problem", variant="primary")
178
  status_output = gr.Textbox(label="Status", interactive=False)
179
  results_table = gr.Dataframe(label="Optimization Results")
180
  results_plot = gr.Plot(label="Results Visualization")
181
 
182
+ def _solve_diet_optimization(foods_df, requirements_df):
183
  try:
184
+ logger.info("Starting diet optimization from UI")
185
+
186
+ # Input existence validation
187
+ if foods_df is None or len(foods_df) == 0:
188
+ return (
189
+ pd.DataFrame(),
190
+ None,
191
+ "❌ Error: Please provide foods data. Add at least one food item with its cost and nutritional content.",
192
+ )
193
+
194
+ if requirements_df is None or len(requirements_df) == 0:
195
+ return (
196
+ pd.DataFrame(),
197
+ None,
198
+ "❌ Error: Please provide requirements data. Add at least one nutritional requirement.",
199
+ )
200
+
201
+ # Convert to DataFrame if needed
202
+ if not isinstance(foods_df, pd.DataFrame):
203
+ try:
204
+ foods_df = pd.DataFrame(foods_df)
205
+ except Exception as e:
206
+ return (
207
+ pd.DataFrame(),
208
+ None,
209
+ f"❌ Error: Cannot convert foods data to DataFrame: {str(e)}",
210
+ )
211
+
212
+ if not isinstance(requirements_df, pd.DataFrame):
213
+ try:
214
+ requirements_df = pd.DataFrame(requirements_df)
215
+ except Exception as e:
216
+ return (
217
+ pd.DataFrame(),
218
+ None,
219
+ f"❌ Error: Cannot convert requirements data to DataFrame: {str(e)}",
220
+ )
221
+
222
+ # Remove completely empty rows
223
+ foods_df = foods_df.dropna(how="all")
224
+ requirements_df = requirements_df.dropna(how="all")
225
+
226
+ # Check if data still exists after cleaning
227
+ if foods_df.empty:
228
+ return (
229
+ pd.DataFrame(),
230
+ None,
231
+ "❌ Error: No valid foods data found. Please ensure at least one row has valid data.",
232
+ )
233
+
234
+ if requirements_df.empty:
235
+ return (
236
+ pd.DataFrame(),
237
+ None,
238
+ "❌ Error: No valid requirements data found. Please ensure at least one row has valid data.",
239
+ )
240
+
241
+ # Validate required columns
242
+ if "Food" not in foods_df.columns or "Cost" not in foods_df.columns:
243
+ return (
244
+ pd.DataFrame(),
245
+ None,
246
+ "❌ Error: Foods data must have 'Food' and 'Cost' columns. Please check your column headers.",
247
+ )
248
+
249
+ if (
250
+ "Nutrient" not in requirements_df.columns
251
+ or "Minimum" not in requirements_df.columns
252
+ ):
253
+ return (
254
+ pd.DataFrame(),
255
+ None,
256
+ "❌ Error: Requirements data must have 'Nutrient' and 'Minimum' columns. Please check your column headers.",
257
+ )
258
+
259
+ # Check for missing values in critical columns
260
+ missing_food_names = foods_df["Food"].isna().sum()
261
+ if missing_food_names > 0:
262
+ return (
263
+ pd.DataFrame(),
264
+ None,
265
+ f"❌ Error: {missing_food_names} food(s) have missing names. All foods must have valid names.",
266
+ )
267
+
268
+ missing_costs = foods_df["Cost"].isna().sum()
269
+ if missing_costs > 0:
270
+ return (
271
+ pd.DataFrame(),
272
+ None,
273
+ f"❌ Error: {missing_costs} food(s) have missing costs. All foods must have valid costs.",
274
+ )
275
+
276
+ missing_nutrients = requirements_df["Nutrient"].isna().sum()
277
+ if missing_nutrients > 0:
278
+ return (
279
+ pd.DataFrame(),
280
+ None,
281
+ f"❌ Error: {missing_nutrients} requirement(s) have missing nutrient names. All requirements must have valid nutrient names.",
282
+ )
283
+
284
+ missing_minimums = requirements_df["Minimum"].isna().sum()
285
+ if missing_minimums > 0:
286
+ return (
287
+ pd.DataFrame(),
288
+ None,
289
+ f"❌ Error: {missing_minimums} requirement(s) have missing minimum values. All requirements must have valid minimum values.",
290
+ )
291
+
292
+ # Check for negative values
293
+ try:
294
+ numeric_cols = foods_df.select_dtypes(include=[float, int]).columns
295
+ numeric_cols = [
296
+ col for col in numeric_cols if col != "Food"
297
+ ] # Exclude non-numeric columns
298
+
299
+ if (
300
+ len(numeric_cols) > 0
301
+ and (foods_df[numeric_cols] < 0).any().any()
302
+ ):
303
+ negative_foods = []
304
+ for col in numeric_cols:
305
+ if (foods_df[col] < 0).any():
306
+ bad_foods = foods_df[foods_df[col] < 0]["Food"].tolist()
307
+ negative_foods.extend(
308
+ [f"{food} ({col})" for food in bad_foods]
309
+ )
310
+
311
+ return (
312
+ pd.DataFrame(),
313
+ None,
314
+ f"❌ Error: Negative values found: {', '.join(negative_foods[:5])}{'...' if len(negative_foods) > 5 else ''}. All numeric values must be non-negative.",
315
+ )
316
+ except Exception as numeric_error:
317
+ return (
318
+ pd.DataFrame(),
319
+ None,
320
+ f"❌ Error: Problem checking numeric values: {str(numeric_error)}. Please ensure all numeric columns contain valid numbers.",
321
+ )
322
+
323
+ try:
324
+ if (requirements_df["Minimum"] <= 0).any():
325
+ bad_requirements = requirements_df[
326
+ requirements_df["Minimum"] <= 0
327
+ ]["Nutrient"].tolist()
328
+ return (
329
+ pd.DataFrame(),
330
+ None,
331
+ f"❌ Error: Non-positive requirements found for: {', '.join(bad_requirements)}. All requirements must be positive values.",
332
+ )
333
+ except Exception as req_error:
334
+ return (
335
+ pd.DataFrame(),
336
+ None,
337
+ f"❌ Error: Problem checking requirements: {str(req_error)}. Please ensure all requirement values are positive numbers.",
338
+ )
339
+
340
+ # Check for empty strings in food names
341
+ empty_food_names = foods_df[
342
+ foods_df["Food"].astype(str).str.strip() == ""
343
+ ]["Food"].count()
344
+ if empty_food_names > 0:
345
+ return (
346
+ pd.DataFrame(),
347
+ None,
348
+ f"❌ Error: {empty_food_names} food(s) have empty names. All foods must have non-empty names.",
349
+ )
350
+
351
+ # Check for empty strings in nutrient names
352
+ empty_nutrient_names = requirements_df[
353
+ requirements_df["Nutrient"].astype(str).str.strip() == ""
354
+ ]["Nutrient"].count()
355
+ if empty_nutrient_names > 0:
356
+ return (
357
+ pd.DataFrame(),
358
+ None,
359
+ f"❌ Error: {empty_nutrient_names} nutrient(s) have empty names. All nutrients must have non-empty names.",
360
+ )
361
+
362
+ logger.info("UI validation passed, calling solver...")
363
+ result_df, fig = solve_diet_problem(foods_df, requirements_df)
364
+
365
+ # Validate solver results
366
+ if result_df is None or result_df.empty:
367
+ return (
368
+ pd.DataFrame(),
369
+ None,
370
+ "❌ Error: Solver returned empty results. This is unexpected.",
371
+ )
372
+
373
+ # Check if solution makes sense
374
+ total_cost = (
375
+ result_df["Cost"].sum() if "Cost" in result_df.columns else 0
376
+ )
377
+ if total_cost < 0:
378
+ logger.warning(f"Negative total cost detected: {total_cost}")
379
+
380
+ logger.info(
381
+ f"Optimization completed successfully with total cost: {total_cost:.2f}"
382
+ )
383
+ return (
384
+ result_df,
385
+ fig,
386
+ f"✅ Solved Successfully! Optimal diet plan found with total cost: ${total_cost:.2f}",
387
  )
388
+
389
+ except ValueError as ve:
390
+ logger.error(f"Validation error: {str(ve)}")
391
+ return pd.DataFrame(), None, f"❌ Validation Error: {str(ve)}"
392
+ except TypeError as te:
393
+ logger.error(f"Type error: {str(te)}")
394
+ return pd.DataFrame(), None, f"❌ Data Type Error: {str(te)}"
395
  except Exception as e:
396
+ logger.error(f"Unexpected error in diet optimization: {str(e)}")
397
+ error_msg = str(e)
398
+ if "Gurobi" in error_msg:
399
+ return pd.DataFrame(), None, f"❌ Solver Error: {error_msg}"
400
+ elif "infeasible" in error_msg.lower():
401
+ return pd.DataFrame(), None, f"❌ Infeasible Problem: {error_msg}"
402
+ elif "unbounded" in error_msg.lower():
403
+ return pd.DataFrame(), None, f"❌ Unbounded Problem: {error_msg}"
404
+ else:
405
+ return pd.DataFrame(), None, f"❌ Unexpected Error: {error_msg}"
406
 
407
  solve_btn.click(
408
+ fn=_solve_diet_optimization,
409
+ inputs=[foods_input, requirements_input],
410
  outputs=[results_table, results_plot, status_output],
411
  )
412
 
 
518
  fn=_solve_vrp_with_floats,
519
  inputs=[vrp_input, cap_input, k_input],
520
  outputs=[result_table, result_plot, status_output],
521
+ )
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .logger import get_logger
{achref/src/logger → utils}/logger.py RENAMED
@@ -9,7 +9,6 @@ from typing import Any, Callable
9
  load_dotenv()
10
 
11
 
12
-
13
  LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
14
 
15
  # Mapping of string levels to logging constants
@@ -18,33 +17,37 @@ LOG_LEVEL_MAPPING = {
18
  "INFO": logging.INFO,
19
  "WARNING": logging.WARNING,
20
  "ERROR": logging.ERROR,
21
- "CRITICAL": logging.CRITICAL
22
  }
23
 
24
  # Set the actual logging level
25
  LOG_LEVEL = LOG_LEVEL_MAPPING.get(LOG_LEVEL, logging.INFO) # Default to INFO if invalid
26
 
 
27
  # Custom colorized formatter to apply colors specifically to log levels
28
  class CustomColourizedFormatter(ColourizedFormatter):
29
  def format(self, record: logging.LogRecord) -> str:
30
  # Define color mappings for different log levels
31
  level_color_map = {
32
- "DEBUG": "\033[34m", # Blue
33
- "INFO": "\033[32m", # Green
34
  "WARNING": "\033[33m", # Yellow
35
- "ERROR": "\033[31m", # Red
36
- "CRITICAL": "\033[41m", # Red background
37
  }
38
 
39
  # Reset color
40
  reset = "\033[0m"
41
 
42
  # Apply color to the log level name
43
- record.levelname = f"{level_color_map.get(record.levelname, '')}{record.levelname}{reset}"
 
 
44
 
45
  # Format the log message using the parent class's format method
46
  return super().format(record)
47
 
 
48
  def get_logger(name: str) -> logging.Logger:
49
  """Creates a logger object that reads its log level from .env.
50
 
@@ -52,7 +55,7 @@ def get_logger(name: str) -> logging.Logger:
52
  name (str): name given to the logger
53
 
54
  Returns:
55
- logging.Logger: logger object to be used for logging
56
  """
57
  # Create logger
58
  logger = logging.getLogger(name)
@@ -69,7 +72,7 @@ def get_logger(name: str) -> logging.Logger:
69
  "{asctime} | {levelname:<8} | {message}",
70
  style="{",
71
  datefmt="%Y-%m-%d %H:%M:%S",
72
- use_colors=True
73
  )
74
 
75
  # Add formatter to the handler
@@ -80,26 +83,32 @@ def get_logger(name: str) -> logging.Logger:
80
 
81
  return logger
82
 
 
83
  # Logger decorator implementation
84
  def log_function_call(logger: logging.Logger) -> Callable:
85
  """A decorator that logs the function calls and results.
86
 
87
  Args:
88
  logger (logging.Logger): The logger instance to use for logging.
89
-
90
  Returns:
91
  Callable: A wrapper function that logs the execution details.
92
  """
 
93
  def decorator(func: Callable) -> Callable:
94
  @functools.wraps(func)
95
  def wrapper(*args, **kwargs) -> Any:
96
  # Log the function call with arguments
97
- logger.debug(f"Calling {func.__name__} with args: {args} and kwargs: {kwargs}")
 
 
98
  result = func(*args, **kwargs)
99
  # Log the function result
100
  logger.debug(f"{func.__name__} returned {result}")
101
  return result
 
102
  return wrapper
 
103
  return decorator
104
 
105
 
 
9
  load_dotenv()
10
 
11
 
 
12
  LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
13
 
14
  # Mapping of string levels to logging constants
 
17
  "INFO": logging.INFO,
18
  "WARNING": logging.WARNING,
19
  "ERROR": logging.ERROR,
20
+ "CRITICAL": logging.CRITICAL,
21
  }
22
 
23
  # Set the actual logging level
24
  LOG_LEVEL = LOG_LEVEL_MAPPING.get(LOG_LEVEL, logging.INFO) # Default to INFO if invalid
25
 
26
+
27
  # Custom colorized formatter to apply colors specifically to log levels
28
  class CustomColourizedFormatter(ColourizedFormatter):
29
  def format(self, record: logging.LogRecord) -> str:
30
  # Define color mappings for different log levels
31
  level_color_map = {
32
+ "DEBUG": "\033[34m", # Blue
33
+ "INFO": "\033[32m", # Green
34
  "WARNING": "\033[33m", # Yellow
35
+ "ERROR": "\033[31m", # Red
36
+ "CRITICAL": "\033[41m", # Red background
37
  }
38
 
39
  # Reset color
40
  reset = "\033[0m"
41
 
42
  # Apply color to the log level name
43
+ record.levelname = (
44
+ f"{level_color_map.get(record.levelname, '')}{record.levelname}{reset}"
45
+ )
46
 
47
  # Format the log message using the parent class's format method
48
  return super().format(record)
49
 
50
+
51
  def get_logger(name: str) -> logging.Logger:
52
  """Creates a logger object that reads its log level from .env.
53
 
 
55
  name (str): name given to the logger
56
 
57
  Returns:
58
+ logging.Logger: logger object to be used for logging
59
  """
60
  # Create logger
61
  logger = logging.getLogger(name)
 
72
  "{asctime} | {levelname:<8} | {message}",
73
  style="{",
74
  datefmt="%Y-%m-%d %H:%M:%S",
75
+ use_colors=True,
76
  )
77
 
78
  # Add formatter to the handler
 
83
 
84
  return logger
85
 
86
+
87
  # Logger decorator implementation
88
  def log_function_call(logger: logging.Logger) -> Callable:
89
  """A decorator that logs the function calls and results.
90
 
91
  Args:
92
  logger (logging.Logger): The logger instance to use for logging.
93
+
94
  Returns:
95
  Callable: A wrapper function that logs the execution details.
96
  """
97
+
98
  def decorator(func: Callable) -> Callable:
99
  @functools.wraps(func)
100
  def wrapper(*args, **kwargs) -> Any:
101
  # Log the function call with arguments
102
+ logger.debug(
103
+ f"Calling {func.__name__} with args: {args} and kwargs: {kwargs}"
104
+ )
105
  result = func(*args, **kwargs)
106
  # Log the function result
107
  logger.debug(f"{func.__name__} returned {result}")
108
  return result
109
+
110
  return wrapper
111
+
112
  return decorator
113
 
114