@@ -95,37 +95,41 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
9595 f"Shift_{ s ['id' ]} " ,
9696 )
9797
98- # Each worker must be assigned to at least their minimum number of shifts
9998 for e in workers :
10099 rules = rules_per_worker [e ["id" ]]
101- solver .Add (
102- solver .Sum ([x_assign [(e ["id" ], s ["id" ])] for s in shifts ]) >= rules ["min_shifts" ],
103- f"worker_{ e ['id' ]} " ,
104- )
100+
101+ # Each worker must be assigned to at least their minimum number of shifts
102+ if "min_shifts" in rules :
103+ solver .Add (
104+ solver .Sum ([x_assign [(e ["id" ], s ["id" ])] for s in shifts ]) >= rules ["min_shifts" ],
105+ f"worker_{ e ['id' ]} " ,
106+ )
105107
106108 # Each worker must be assigned to at most their maximum number of shifts
107- solver .Add (
108- solver .Sum ([x_assign [(e ["id" ], s ["id" ])] for s in shifts ]) <= rules ["max_shifts" ],
109- f"worker_{ e ['id' ]} " ,
110- )
109+ if "max_shifts" in rules :
110+ solver .Add (
111+ solver .Sum ([x_assign [(e ["id" ], s ["id" ])] for s in shifts ]) <= rules ["max_shifts" ],
112+ f"worker_{ e ['id' ]} " ,
113+ )
111114
112115 # Ensure that the minimum rest time between shifts is respected
113- rest_time = datetime .timedelta (hours = rules_per_worker [e ["id" ]]["min_rest_hours_between_shifts" ])
114- for s1 , shift1 in enumerate (shifts ):
115- for s2 , shift2 in enumerate (shifts ):
116- if s1 >= s2 :
117- continue
118- if (
119- shift1 ["end_time" ] + rest_time < shift2 ["start_time" ]
120- or shift2 ["end_time" ] + rest_time < shift1 ["start_time" ]
121- ):
122- continue
123- # The two shifts are closer to each other than the minimum rest time, so we need to ensure that
124- # the worker is not assigned to both.
125- solver .Add (
126- x_assign [(e ["id" ], shift1 ["id" ])] + x_assign [(e ["id" ], shift2 ["id" ])] <= 1 ,
127- f"Rest_{ e ['id' ]} _{ shift1 ['id' ]} _{ shift2 ['id' ]} " ,
128- )
116+ if "min_rest_hours_between_shifts" in rules :
117+ rest_time = datetime .timedelta (hours = rules ["min_rest_hours_between_shifts" ])
118+ for s1 , shift1 in enumerate (shifts ):
119+ for s2 , shift2 in enumerate (shifts ):
120+ if s1 >= s2 :
121+ continue
122+ if (
123+ shift1 ["end_time" ] + rest_time < shift2 ["start_time" ]
124+ or shift2 ["end_time" ] + rest_time < shift1 ["start_time" ]
125+ ):
126+ continue
127+ # The two shifts are closer to each other than the minimum rest time, so we need to ensure that
128+ # the worker is not assigned to both.
129+ solver .Add (
130+ x_assign [(e ["id" ], shift1 ["id" ])] + x_assign [(e ["id" ], shift2 ["id" ])] <= 1 ,
131+ f"Rest_{ e ['id' ]} _{ shift1 ['id' ]} _{ shift2 ['id' ]} " ,
132+ )
129133
130134 # Ensure that availabilities are respected
131135 for s in shifts :
@@ -146,34 +150,34 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
146150 # The worker does not have the required qualification (worker cannot be assigned)
147151 x_assign [(e ["id" ], s ["id" ])].SetBounds (0 , 0 )
148152
149- # Ensure that the minimum and maximum work hours per day are respected
150153 for day in range ((latest_shift_end_time - earliest_shift_start_time ).days + 1 ):
154+ # Ensure that the minimum and maximum work hours per day are respected
151155 day_start = earliest_shift_start_time + datetime .timedelta (days = day )
152156 day_end = day_start + datetime .timedelta (days = 1 )
153- solver .Add (
154- solver .Sum (
155- [
156- x_assign [(e ["id" ], s ["id" ])]
157- * ((min (s ["end_time" ], day_end ) - max (s ["start_time" ], day_start )).total_seconds () / 3600 )
158- for s in shifts
159- if s ["start_time" ] < day_end and s ["end_time" ] >= day_start
160- ]
157+ if "max_work_hours_per_day" in rules :
158+ solver .Add (
159+ solver .Sum (
160+ [
161+ x_assign [(e ["id" ], s ["id" ])] * overlap (s , day_start , day_end )
162+ for s in shifts
163+ if overlap (s , day_start , day_end ) > 0
164+ ]
165+ )
166+ <= rules ["max_work_hours_per_day" ],
167+ f"MaxWorkHours_{ e ['id' ]} _{ day } " ,
161168 )
162- <= rules_per_worker [ e [ "id" ]][ "max_work_hours_per_day" ],
163- f"MaxWorkHours_ { e [ 'id' ] } _ { day } " ,
164- )
165- solver . Add (
166- solver . Sum (
167- [
168- x_assign [( e [ "id" ], s [ "id" ])]
169- * (( min ( s [ "end_time" ], day_end ) - max ( s [ "start_time" ], day_start )). total_seconds () / 3600 )
170- for s in shifts
171- if s [ "start_time" ] < day_end and s [ "end_time" ] >= day_start
172- ]
169+ if "min_work_hours_per_day" in rules :
170+ solver . Add (
171+ solver . Sum (
172+ [
173+ x_assign [( e [ "id" ], s [ "id" ])] * overlap ( s , day_start , day_end )
174+ for s in shifts
175+ if overlap ( s , day_start , day_end ) > 0
176+ ]
177+ )
178+ >= rules [ "min_work_hours_per_day" ],
179+ f"MinWorkHours_ { e [ 'id' ] } _ { day } " ,
173180 )
174- >= rules_per_worker [e ["id" ]]["min_work_hours_per_day" ],
175- f"MinWorkHours_{ e ['id' ]} _{ day } " ,
176- )
177181 # Ensure total hours worked by each worker are correctly calculated
178182 for e in workers :
179183 solver .Add (
@@ -188,28 +192,34 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
188192 )
189193
190194 # Ensure that the maximum work hours per week are respected
191- for week in range ((latest_shift_end_time - earliest_shift_start_time ).days // 7 + 1 ):
192- week_start = earliest_shift_start_time + datetime .timedelta (weeks = week )
193- week_end = week_start + datetime .timedelta (days = 7 )
194- solver .Add (
195- solver .Sum (
196- [
197- x_assign [(e ["id" ], s ["id" ])]
198- * ((min (s ["end_time" ], week_end ) - max (s ["start_time" ], week_start )).total_seconds () / 3600 )
199- for s in shifts
200- if s ["start_time" ] < week_end and s ["end_time" ] > week_start
201- ]
195+ if "max_work_hours_per_week" in rules :
196+ for week in range ((latest_shift_end_time - earliest_shift_start_time ).days // 7 + 1 ):
197+ week_start = earliest_shift_start_time + datetime .timedelta (weeks = week )
198+ week_end = week_start + datetime .timedelta (days = 7 )
199+ solver .Add (
200+ solver .Sum (
201+ [
202+ x_assign [(e ["id" ], s ["id" ])] * overlap (s , week_start , week_end )
203+ for s in shifts
204+ if overlap (s , week_start , week_end ) > 0
205+ ]
206+ )
207+ <= rules ["max_work_hours_per_week" ],
208+ f"MaxWorkHours_{ e ['id' ]} _Week{ week } " ,
202209 )
203- <= rules_per_worker [e ["id" ]]["max_work_hours_per_week" ],
204- f"MaxWorkHours_{ e ['id' ]} _Week{ week } " ,
205- )
210+
211+ # Calculate deviation from mean hours worked
212+ balance_hours_weight = input .options .factor_balance_total_hours
213+ if balance_hours_weight > 0 :
214+ avg_hours = solver .Sum ([total_hours [e ["id" ]] for e in workers ]) / len (workers )
215+ for e in workers :
216+ deviation = total_hours [e ["id" ]] - avg_hours
217+ solver .Add (deviations [e ["id" ]] == deviation )
206218
207219 # >>> Objective
208220 objective = solver .Objective ()
209221 preference_weight = input .options .factor_maximize_preferences
210- balance_hours_weight = input .options .factor_balance_total_hours
211222 weekly_hours_weight = input .options .factor_maximize_weekly_hours_per_worker
212- avg_hours = solver .Sum ([total_hours [e ["id" ]] for e in workers ]) / len (workers )
213223
214224 for e in workers :
215225 # Maximize preferences
@@ -219,12 +229,12 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
219229 objective .SetCoefficient (x_assign [(e ["id" ], s ["id" ])], pref * preference_weight )
220230
221231 # Minimize variance in total hours worked
222- deviation = total_hours [e ["id" ]] - avg_hours
223- solver .Add (deviations [e ["id" ]] == deviation )
224- objective .SetCoefficient (deviations [e ["id" ]], - balance_hours_weight )
232+ if balance_hours_weight > 0 :
233+ objective .SetCoefficient (deviations [e ["id" ]], - balance_hours_weight )
225234
226235 # Maximize total hours worked up to the maximum allowed
227- objective .SetCoefficient (total_hours [e ["id" ]], weekly_hours_weight )
236+ if weekly_hours_weight > 0 :
237+ objective .SetCoefficient (total_hours [e ["id" ]], weekly_hours_weight )
228238
229239 objective .SetMaximization ()
230240
@@ -287,6 +297,11 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
287297 )
288298
289299
300+ def overlap (shift : dict , start : datetime .datetime , end : datetime .datetime ) -> float :
301+ """Calculates the overlap between a shift and a time interval. Returns the overlap in hours."""
302+ return max (0 , (min (shift ["end_time" ], end ) - max (shift ["start_time" ], start )).total_seconds () / 3600 )
303+
304+
290305def convert_input (input_data : dict [str , Any ]) -> tuple [list , list , dict ]:
291306 """Converts the input data to the format expected by the model."""
292307 workers = input_data ["workers" ]
@@ -301,14 +316,6 @@ def convert_input(input_data: dict[str, Any]) -> tuple[list, list, dict]:
301316 a ["start_time" ] = datetime .datetime .fromisoformat (a ["start_time" ])
302317 a ["end_time" ] = datetime .datetime .fromisoformat (a ["end_time" ])
303318
304- # Add default values for rules
305- for r in input_data ["rules" ]:
306- r ["min_shifts" ] = r .get ("min_shifts" , 0 )
307- r ["max_shifts" ] = r .get ("max_shifts" , 1000 )
308- r ["min_work_hours_per_day" ] = r .get ("min_work_hours_per_day" , 0 )
309- r ["max_work_hours_per_day" ] = r .get ("max_work_hours_per_day" , 24 )
310- r ["max_work_hours_per_week" ] = r .get ("max_work_hours_per_week" , 24 * 7 )
311-
312319 # Add default values for workers
313320 for e in workers :
314321 e ["preferences" ] = e .get ("preferences" , {})
0 commit comments