diff --git a/rota/shifts.py b/rota/shifts.py index aef9afd..43bebcf 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -47,8 +47,8 @@ SHIFT_BOUNDS = { class SingleShift(object): """Class to hold all details for a shift - - + + Valid constraints balance_across_groups (requires block assignment) @@ -56,14 +56,14 @@ class SingleShift(object): postclear(2) / preclear(2) night - + limit_grade_number: options: {grade: max_number, grade2: max_number2} minimum_grade_number: options: (grade, min_number) - + """ def __init__( @@ -199,8 +199,8 @@ class RotaBuilder(object): "balance_nights_across_sites": True, "balance_bank_holidays": True, "balance_blocks": True, - "balance_shifts": True, # Does not use a quadratic function - "balance_shifts_quadratic": False, + "balance_shifts": True, # Does not use a quadratic function + "balance_shifts_quadratic": False, "balance_shifts_over_workers": True, "minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers "balance_weekends": True, @@ -266,7 +266,6 @@ class RotaBuilder(object): self.solve_model(options=options) - def build_model(self): # Initialize model self.model = ConcreteModel() @@ -283,15 +282,6 @@ class RotaBuilder(object): initialize=0, ) - # The nights self.model is used to ensure nights are assigned as a block (and limit the number of workers required) - # self.model.nights = Var( - # ((worker.id, week, block) for worker in self.workers - # for week in self.weeks for block in self.night_blocks), - # within=Binary, - # initialize=0, - # ) - - # The nights self.model is used to ensure nights are assigned as a block (and limit the number of workers required) self.model.shift_week_worker_assigned = Var( ( (shift, week, worker.id) @@ -347,13 +337,6 @@ class RotaBuilder(object): within=NonNegativeIntegers, initialize=0, ) - # self.model.bank_holiday_count = Var( - # ((worker.id, week, day) for worker in self.workers - # for week, day in self.get_week_day_combinations() if self.week_day_date_map[(week, day)] in bank_holiday_map - # ), - # within=Binary, - # initialize=0, - # ) # Used to limit number of workers on night shift per site # if we force a binary it will in effect hard constrain to < 2 @@ -466,7 +449,10 @@ class RotaBuilder(object): initialize=0, ) - if self.constraint_options["balance_shifts"] or self.constraint_options["balance_shifts_quadratic"]: + if ( + self.constraint_options["balance_shifts"] + or self.constraint_options["balance_shifts_quadratic"] + ): self.model.shift_count_t1 = Var( ( (worker.id, shift.name) @@ -558,7 +544,6 @@ class RotaBuilder(object): # initialize=0, # ) - self.model.works_weekend = Var( ((worker.id, week) for worker in self.workers for week in self.weeks), domain=Binary, @@ -696,16 +681,6 @@ class RotaBuilder(object): # self.model.works[worker.id, week, day, shift] # for worker in self.workers if worker.site not in site_required)) - # THIS SHOULD NO LONGER BE REQUIRED - # Ensure no workers are assigned to shifts that are not required - #for week, day, shift in self.get_not_required_shifts(): - # self.model.constraints.add( - # 0 - # == sum( - # self.model.works[worker.id, week, day, shift.name] - # for worker in self.workers - # ) - # ) # Constraint: total hours worked hours worked # for worker in self.workers: @@ -757,23 +732,29 @@ class RotaBuilder(object): for shift in self.get_shifts_with_constraint("limit_grade_number"): if not shift.constraint_options["limit_grade_number"]: - raise ValueError("Constraint option must be defined for 'limit_grade_number'") + raise ValueError( + "Constraint option must be defined for 'limit_grade_number'" + ) for grade in shift.constraint_options["limit_grade_number"]: - #self.model.limit_grades_constraint = Constraint( - setattr(self.model, f"limit_grades_constraint_{shift.name}_{grade}", Constraint( - [week for week in self.weeks], - #[shift for shift in self.get_shifts_with_constraint("limit_grade_number")], - [shift], - [grade], - [shift.constraint_options["limit_grade_number"][grade]], - rule=limitGradesRule, - #name=f"limit_grades_constraint_{shift.name}_{grade}" - )) + # self.model.limit_grades_constraint = Constraint( + setattr( + self.model, + f"limit_grades_constraint_{shift.name}_{grade}", + Constraint( + [week for week in self.weeks], + # [shift for shift in self.get_shifts_with_constraint("limit_grade_number")], + [shift], + [grade], + [shift.constraint_options["limit_grade_number"][grade]], + rule=limitGradesRule, + # name=f"limit_grades_constraint_{shift.name}_{grade}" + ), + ) def minimumGradesRule(model, week, shift, grade, limit): workers = [w for w in self.workers if w.grade >= grade] if len(workers) < limit: - #return Constraint.Skip + # return Constraint.Skip raise ValueError("minimum_grade_number: not enough valid workers") return ( sum( @@ -785,19 +766,24 @@ class RotaBuilder(object): for shift in self.get_shifts_with_constraint("minimum_grade_number"): if not shift.constraint_options["minimum_grade_number"]: - raise ValueError("Constraint option must be defined for 'minimum_grade_number'") + raise ValueError( + "Constraint option must be defined for 'minimum_grade_number'" + ) grade, min_required = shift.constraint_options["minimum_grade_number"] - #self.model.limit_grades_constraint = Constraint( - setattr(self.model, f"minimum_grade_number_{shift.name}", Constraint( - [week for week in self.weeks], - #[shift for shift in self.get_shifts_with_constraint("limit_grade_number")], - [shift], - [grade], - [min_required], - rule=minimumGradesRule, - #name=f"limit_grades_constraint_{shift.name}_{grade}" - )) - + # self.model.limit_grades_constraint = Constraint( + setattr( + self.model, + f"minimum_grade_number_{shift.name}", + Constraint( + [week for week in self.weeks], + # [shift for shift in self.get_shifts_with_constraint("limit_grade_number")], + [shift], + [grade], + [min_required], + rule=minimumGradesRule, + # name=f"limit_grades_constraint_{shift.name}_{grade}" + ), + ) # def nightShiftDerrifordRule(model, week, shift): # derriford_workers = [w for w in self.workers if w.night_at_derriford >= 1] @@ -831,21 +817,26 @@ class RotaBuilder(object): >= required_number ) - for shift in self.get_shifts_with_constraint("require_home_site_presence"): - site, required_number = shift.constraint_options["require_home_site_presence"] - #self.model.require_presence_at_site_overnight_rule = Constraint( - setattr(self.model, f"require_home_site_presence_{shift.name}", Constraint( - [week for week in self.weeks], - [shift.name], - [site], - [required_number], - rule=presenceAtHomeSite, - )) + site, required_number = shift.constraint_options[ + "require_home_site_presence" + ] + # self.model.require_presence_at_site_overnight_rule = Constraint( + setattr( + self.model, + f"require_home_site_presence_{shift.name}", + Constraint( + [week for week in self.weeks], + [shift.name], + [site], + [required_number], + rule=presenceAtHomeSite, + ), + ) # # Count the number of workers from each site on each night shift # # As 1 or 0 is optimum we can simply subtract 1 from the number - #if self.constraint_options["balance_nights_across_sites"]: + # if self.constraint_options["balance_nights_across_sites"]: # for site in self.sites: # for shift in self.get_shifts_with_constraint("night"): # block = shift.name @@ -1032,12 +1023,10 @@ class RotaBuilder(object): print(e) # Count number of weekends an worker works - #if self.constraint_options["balance_weekends"]: + # if self.constraint_options["balance_weekends"]: self.model.constraints.add( self.model.worker_weekend_count[worker.id] - == sum( - self.model.works_weekend[worker.id, week] for week in self.weeks - ) + == sum(self.model.works_weekend[worker.id, week] for week in self.weeks) ) if self.constraint_options["max_weekends"] > -1: @@ -1102,9 +1091,11 @@ class RotaBuilder(object): min_shifts, sum( self.model.works[worker.id, week, day, shift.name] - for week, day in self.get_week_day_combinations_for_shift(shift) - #for week, day in self.get_week_day_combinations() - #if shift.name in self.get_shift_names_by_week_day(week, day) + for week, day in self.get_week_day_combinations_for_shift( + shift + ) + # for week, day in self.get_week_day_combinations() + # if shift.name in self.get_shift_names_by_week_day(week, day) ), max_shifts, ) @@ -1115,8 +1106,10 @@ class RotaBuilder(object): 0 == sum( self.model.works[worker.id, week, day, shift.name] - for week, day in self.get_week_day_combinations_for_shift(shift) - #for week, day in self.get_week_day_combinations() + for week, day in self.get_week_day_combinations_for_shift( + shift + ) + # for week, day in self.get_week_day_combinations() ) ) @@ -1143,7 +1136,6 @@ class RotaBuilder(object): == self.model.shift_count_diff[worker.id, shift.name] ) - if self.constraint_options["balance_shifts_quadratic"]: # This may need to be updated xU = 25 @@ -1182,7 +1174,6 @@ class RotaBuilder(object): - xU * xU ) - # Define worker_shift_count_t1 and worker_shift_count_t2 constraints for the object # Thus bypassing the need for a quadratic solver # t1-t2 is the target @@ -1241,9 +1232,11 @@ class RotaBuilder(object): self.model.night_shift_count[worker.id] == sum( self.model.works[worker.id, week, day, shift.name] - for week, day, shift in self.get_week_day_shift_combinations_for_constraint("night") - #for week, day in self.get_week_day_combinations_for_shift(shift) - #for shift in self.get_shifts_with_constraint("night") + for week, day, shift in self.get_week_day_shift_combinations_for_constraint( + "night" + ) + # for week, day in self.get_week_day_combinations_for_shift(shift) + # for shift in self.get_shifts_with_constraint("night") ) ) @@ -1387,10 +1380,8 @@ class RotaBuilder(object): ) ) - #if self.constraint_options["balance_weekends"]: - for week_blocks in self.get_week_block_iterator( - self.max_weekend_frequency - ): + # if self.constraint_options["balance_weekends"]: + for week_blocks in self.get_week_block_iterator(self.max_weekend_frequency): # Prevent weekend shifts more than once every n weeks self.model.constraints.add( 1 @@ -1406,14 +1397,16 @@ class RotaBuilder(object): self.constraint_options["max_shifts_per_week"] >= sum( self.model.works[worker.id, week, day, shiftname] - #for shiftname in self.get_shift_names_by_week_day(week, day) - #for day in self.days - for w, day, shiftname in self.get_all_shiftname_combinations(week=week) + # for shiftname in self.get_shift_names_by_week_day(week, day) + # for day in self.days + for w, day, shiftname in self.get_all_shiftname_combinations( + week=week + ) ) ) # TODO: generic implementation - #for shift in self.get_shifts_with_constraint("max_2_shifts_per_week"): + # for shift in self.get_shifts_with_constraint("max_2_shifts_per_week"): # self.model.constraints.add( # 2 # >= sum( @@ -1431,16 +1424,22 @@ class RotaBuilder(object): self.model.constraints.add( full_weekend_count >= sum( - self.model.works[worker.id, week, "Sat", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week, "Sat", shift] + for shift in self.get_shift_names_by_week_day( + week, "Sat" + ) ) + sum( - self.model.works[worker.id, week, "Sun", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week, "Sun", shift] + for shift in self.get_shift_names_by_week_day( + week, "Sun" + ) ) + sum( - self.model.works[worker.id, week + 1, "Mon", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week + 1, "Mon", shift] + for shift in self.get_shift_names_by_week_day( + week+1, "Mon" + ) ) ) if ( @@ -1454,20 +1453,31 @@ class RotaBuilder(object): self.model.constraints.add( full_weekend_count >= sum( - self.model.works[worker.id, week, "Sat", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week, "Sat", shift] + for shift in self.get_shift_names_by_week_day( + week, "Sat" + ) ) + sum( - self.model.works[worker.id, week, "Sun", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week, "Sun", shift] + for shift in self.get_shift_names_by_week_day( + week, "Sun" + ) + # for shift in self.get_shifts() ) + sum( - self.model.works[worker.id, week + 1, "Mon", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week + 1, "Mon", shift] + for shift in self.get_shift_names_by_week_day( + week + 1, "Mon" + ) + # for shift in self.get_shifts() ) + sum( - self.model.works[worker.id, week + 1, "Tue", shift.name] - for shift in self.get_shifts() + self.model.works[worker.id, week + 1, "Tue", shift] + for shift in self.get_shift_names_by_week_day( + week + 1, "Tue" + ) + # for shift in self.get_shifts() ) ) if ( @@ -1528,12 +1538,15 @@ class RotaBuilder(object): # self.model.works[worker.id, week, day, shift.name] # for day in self.days[5:] # for shift in self.get_shifts()) / 2) - #if self.constraint_options["balance_weekends"]: + # if self.constraint_options["balance_weekends"]: self.model.constraints.add( self.model.works_weekend[worker.id, week] >= sum( self.model.works[worker.id, week, day, shiftname] - for w, day, shiftname in self.get_all_shiftname_combinations(week=week) if day in self.days[5:] + for w, day, shiftname in self.get_all_shiftname_combinations( + week=week + ) + if day in self.days[5:] ) / 2 ) @@ -1541,7 +1554,10 @@ class RotaBuilder(object): self.model.works_weekend[worker.id, week] <= sum( self.model.works[worker.id, week, day, shiftname] - for w, day, shiftname in self.get_all_shiftname_combinations(week=week) if day in self.days[5:] + for w, day, shiftname in self.get_all_shiftname_combinations( + week=week + ) + if day in self.days[5:] ) ) # self.model.constraints.add( @@ -1557,7 +1573,7 @@ class RotaBuilder(object): # self.model.shift_week_worker_assigned[s.name, week, worker.id] # for s in self.get_shifts_with_constraint("night"))) - #for shift in self.get_shifts_with_constraint("night"): + # for shift in self.get_shifts_with_constraint("night"): for shift in self.get_shifts(): if shift.force_as_block: # Force nights to be assigned in blocks @@ -1663,10 +1679,14 @@ class RotaBuilder(object): if day in constraint_shift.shift_days: self.model.constraints.add( 1 - >= self.model.works[worker.id, week, day, constraint_shift.name] + >= self.model.works[ + worker.id, week, day, constraint_shift.name + ] + sum( p1 * self.model.works[w.id, pweek, pday, shiftname] - for shiftname in self.get_shift_names_by_week_day(pweek, pday) + for shiftname in self.get_shift_names_by_week_day( + pweek, pday + ) if shiftname != constraint_shift.name for w in workers ) @@ -1676,10 +1696,14 @@ class RotaBuilder(object): if day in constraint_shift.shift_days: self.model.constraints.add( 1 - >= self.model.works[worker.id, week, day, constraint_shift.name] + >= self.model.works[ + worker.id, week, day, constraint_shift.name + ] + sum( p2 * self.model.works[w.id, p2week, p2day, shiftname] - for shiftname in self.get_shift_names_by_week_day(p2week, p2day) + for shiftname in self.get_shift_names_by_week_day( + p2week, p2day + ) if shiftname != constraint_shift.name for w in workers ) @@ -1691,10 +1715,14 @@ class RotaBuilder(object): if day in constraint_shift.shift_days: self.model.constraints.add( 1 - >= self.model.works[worker.id, week, day, constraint_shift.name] + >= self.model.works[ + worker.id, week, day, constraint_shift.name + ] + sum( n1 * self.model.works[w.id, nweek, nday, shiftname] - for shiftname in self.get_shift_names_by_week_day(nweek, nday) + for shiftname in self.get_shift_names_by_week_day( + nweek, nday + ) if shiftname != constraint_shift.name for w in workers ) @@ -1704,18 +1732,28 @@ class RotaBuilder(object): if day in constraint_shift.shift_days: self.model.constraints.add( 1 - >= self.model.works[worker.id, week, day, constraint_shift.name] + >= self.model.works[ + worker.id, week, day, constraint_shift.name + ] + sum( n2 * self.model.works[w.id, n2week, n2day, shiftname] - for shiftname in self.get_shift_names_by_week_day(n2week, n2day) + for shiftname in self.get_shift_names_by_week_day( + n2week, n2day + ) if shiftname != constraint_shift.name for w in workers ) ) + # Night constraint means we won't assign a shift the day before + # an unavailability for constraint_shift in self.get_shifts_with_constraint("night"): - #if day in constraint_shift.shift_days: - if (worker.id, pweek, pday, constraint_shift.name) in self.model.works: + if ( + worker.id, + pweek, + pday, + constraint_shift.name, + ) in self.model.works: # Ensure night prior to unavalibity is not assigned self.model.constraints.add( self.model.available[worker.id, week, day] @@ -1723,86 +1761,6 @@ class RotaBuilder(object): worker.id, pweek, pday, constraint_shift.name ] ) -# # NOTE: use pre / post clear instead -# # DEPRECATED -# # if working a night ensure preceeding (1) or subsequent (2) shifts can only be nights -# if self.constraint_options["constrain_time_off_after_nights"]: -# for constraint_shift in self.get_shifts_with_constraint("night"): -# if day in constraint_shift.shift_days: -# # Ensure night prior to unavalibity is not assigned -# self.model.constraints.add( -# self.model.available[worker.id, week, day] -# >= self.model.works[ -# worker.id, pweek, pday, constraint_shift.name -# ] -# ) -# -# # print("Workers", workers) -# -# self.model.constraints.add( -# 1 -# >= self.model.works[ -# worker.id, week, day, constraint_shift.name -# ] -# + sum( -# n1 * self.model.works[w.id, nweek, nday, shift] -# for shift in self.get_shift_names_by_week_day( -# nweek, nday -# ) -# if shift != constraint_shift.name -# for w in workers -# ) -# ) -# -# self.model.constraints.add( -# 1 -# >= self.model.works[ -# worker.id, week, day, constraint_shift.name -# ] -# + sum( -# n2 * self.model.works[w.id, n2week, n2day, shift] -# for shift in self.get_shift_names_by_week_day( -# n2week, n2day -# ) -# if shift != constraint_shift.name -# for w in workers -# ) -# ) -# # self.model.constraints.add( -# # 1 >= self.model.works[worker.id, week, day, -# # constraint_shift.name] + -# # sum(n3 * self.model.works[worker.id, n3week, n3day, -# # shift] -# # for shift in self.get_shift_names_by_week_day(week, n3day) -# # if shift != constraint_shift.name)) -# self.model.constraints.add( -# 1 -# >= self.model.works[ -# worker.id, week, day, constraint_shift.name -# ] -# + sum( -# p1 * self.model.works[w.id, pweek, pday, shift] -# for shift in self.get_shift_names_by_week_day( -# pweek, pday -# ) -# if shift != constraint_shift.name -# for w in workers -# ) -# ) -# self.model.constraints.add( -# 1 -# >= self.model.works[ -# worker.id, week, day, constraint_shift.name -# ] -# + sum( -# p2 * self.model.works[w.id, p2week, p2day, shift] -# for shift in self.get_shift_names_by_week_day( -# p2week, p2day -# ) -# if shift != constraint_shift.name -# for w in workers -# ) -# ) self.define_objectives() @@ -1932,7 +1890,6 @@ class RotaBuilder(object): else: nights_site_balancing = 0 - if self.get_shifts_with_constraint("balance_across_groups"): block_site_balancing = sum( ( @@ -1942,7 +1899,9 @@ class RotaBuilder(object): * 2000 # self.model.night_per_site2[week, block, site] for week in self.weeks - for shift in self.get_shifts_with_constraint("balance_across_groups") + for shift in self.get_shifts_with_constraint( + "balance_across_groups" + ) for site in self.sites ) else: @@ -2112,32 +2071,34 @@ class RotaBuilder(object): for site in s.site: self.sites.add(site) -# # Todo replace with week_day..... -# self.day_shift_product = [] -# self.day_shiftclass_product = [] -# for s in self.shifts: -# for day in days: -# if day in s.shift_days: -# self.day_shift_product.append((day, s.name)) -# self.day_shiftclass_product.append((day, s)) -# -# def get_day_shiftname_combinations(self): -# """Returns a list of all required day / shift combinations -# -# Returns: -# list: list of in the following format (str->day, str->shift) -# """ -# return self.day_shift_product -# -# def get_day_shiftclass_combinations(self) -> List[Tuple[DayStr, SingleShift]]: -# """Returns a list of all required day / shift combinations -# -# Returns: -# list: list of in the following format (str->day, shiftObject->shift) -# """ -# return self.day_shiftclass_product + # # Todo replace with week_day..... + # self.day_shift_product = [] + # self.day_shiftclass_product = [] + # for s in self.shifts: + # for day in days: + # if day in s.shift_days: + # self.day_shift_product.append((day, s.name)) + # self.day_shiftclass_product.append((day, s)) + # + # def get_day_shiftname_combinations(self): + # """Returns a list of all required day / shift combinations + # + # Returns: + # list: list of in the following format (str->day, str->shift) + # """ + # return self.day_shift_product + # + # def get_day_shiftclass_combinations(self) -> List[Tuple[DayStr, SingleShift]]: + # """Returns a list of all required day / shift combinations + # + # Returns: + # list: list of in the following format (str->day, shiftObject->shift) + # """ + # return self.day_shiftclass_product - def get_all_shiftname_combinations(self, week: int =None, day: int =None) -> List[Tuple[WeekInt, DayStr, ShiftName]]: + def get_all_shiftname_combinations( + self, week: int = None, day: int = None + ) -> List[Tuple[WeekInt, DayStr, ShiftName]]: """Returns a list of all possible week / day / shift combinations Returns: @@ -2154,7 +2115,7 @@ class RotaBuilder(object): return week_day_shifts def get_all_shiftclass_combinations( - self, week: int =None, day: int =None + self, week: int = None, day: int = None ) -> List[Tuple[WeekInt, DayStr, SingleShift]]: """Returns a list of all possible week / day / shift combinations @@ -2181,7 +2142,11 @@ class RotaBuilder(object): return self.weeks_days_product def get_week_day_combinations_for_shift(self, shift) -> list: - return [(week, day) for week, day in self.get_week_day_combinations() if shift.name in self.get_shift_names_by_week_day(week, day)] + return [ + (week, day) + for week, day in self.get_week_day_combinations() + if shift.name in self.get_shift_names_by_week_day(week, day) + ] def get_week_day_shift_combinations_for_constraint(self, constraint) -> list: constraint_shifts = self.get_shifts_with_constraint(constraint) @@ -2605,7 +2570,6 @@ class RotaBuilder(object): bank_holiday_count = -1 bank_holiday_count_w = -1 - # print(worker.name, bank_holiday_count, bank_holiday_count_w) timetable.append( f"{worker_td}{''.join(shift_tds)}"