def shift_happiness_score(self, shift): """Return the happiness shift for a given shift""" s_start_local = self.environment.datetime_utc_to_local(shift.start) s_stop_local = self.environment.datetime_utc_to_local(shift.stop) s_start_day = dt_to_day(s_start_local) s_stop_day = dt_to_day(s_stop_local) score = 0.0 # Right now we ignore partial hours, though we could do fractions if s_start_day == s_stop_day: # Same day for t in range(s_start_local.hour, s_stop_local.hour): if self.preferences[s_start_day] == 1: score += 1 + self.alpha else: score += 1 - self.beta else: # start and end on different days for t in range(s_start_local.hour, HOURS_PER_DAY): if self.preferences[s_start_day] == 1: score += 1 + self.alpha else: score += 1 - self.beta for t in range(s_stop_local.hour): if self.preferences[s_stop_day] == 1: score += 1 + self.alpha else: score += 1 - self.beta return score
def _process_existing_shifts(self): """Set self to active during shifts.""" for s in self.existing_shifts: self.active_days[dt_to_day(s.start)] = True # only mark end day as active if it's within week (aka not overlap) if s.stop < self.environment.stop: self.active_days[dt_to_day(s.stop)] = True
def available_to_work(self, shift): """Check whether the worker can work this shift""" # Existing shifts - check whether violates min hours between or overlap shift.start = self.environment.datetime_utc_to_local(shift.start) shift.stop = self.environment.datetime_utc_to_local(shift.stop) for s in self.existing_shifts: if dt_overlaps( s.start - timedelta( minutes=self.environment.min_minutes_between_shifts), s.stop + timedelta( minutes=self.environment.min_minutes_between_shifts), shift.start, shift.stop): return False # todo - compare to self.availability s_start_local = self.environment.datetime_utc_to_local(shift.start) s_stop_local = self.environment.datetime_utc_to_local(shift.stop) s_start_day = dt_to_day(s_start_local) s_stop_day = dt_to_day(s_stop_local) # What hour to search to - if it's exactly on the hour, then exclude (because search inclusive -> exclusive) if s_stop_local.minute + s_stop_local.second + s_stop_local.microsecond > 0: search_stop_hour = s_stop_local.hour + 1 else: search_stop_hour = s_stop_local.hour # Bug fix - if it's exactly midnight, then roll back day if search_stop_hour is 0: # Roll back start day s_stop_day = s_start_day if s_start_day == s_stop_day: # Same start and stop day for t in range(s_start_local.hour, search_stop_hour): if self.availability[s_start_day][t] != 1: return False else: # Different start and stop days # Day 1 (start -> end of day) for t in range(s_start_local.hour, HOURS_PER_DAY): if self.availability[s_start_day][t] != 1: return False # Day 2 (start of day -> stop) for t in range(search_stop_hour): if self.availability[s_stop_day][t] != 1: return False return True
def _process_time_off_requests(self, to_requests): """Subtract time off requests from availablity and min/max hours""" for r in to_requests: if r.data.get("state") not in APPROVED_TIME_OFF_STATES: logger.info( "Time off request %s skipped because it is in unapproved state %s" % (r.data.get("time_off_request_id"), r.data.get("state"))) continue logger.debug("Processing time off request for user %s: %s" % (self.user_id, r)) self.min_hours_per_workweek -= 1.0 * r.data[ "minutes_paid"] / MINUTES_PER_HOUR if self.min_hours_per_workweek < 0: self.min_hours_per_workweek = 0 self.max_hours_per_workweek -= 1.0 * r.data[ "minutes_paid"] / MINUTES_PER_HOUR if self.max_hours_per_workweek < 0: self.max_hours_per_workweek = 0 # Update availability # Get day of week for request and update availability day_of_week = dt_to_day( self.environment.datetime_utc_to_local( iso8601.parse_date(r.data["start"]))) self.availability[day_of_week] = [0] * HOURS_PER_DAY logger.info("Marked user %s as unavailable on %s due to time off" % (self.user_id, day_of_week))
def _fetch_preceding_day_worked(self): """Fetch from api whether worker worked the day before this week began. Used for consecutive days off. """ logger.debug("Fetching preceding day worked for user %s" % self.user_id) search_end = self.environment.start search_start = search_end - timedelta(days=1) shifts_objs = self._get_role_client().get_shifts( start=dt_to_query_str(search_start), end=dt_to_query_str(search_end), user_id=self.user_id, ) for shift in shifts_objs: # Edge case - mark the first day of week as # active if the person works past midnight if shift.stop > self.environment.start: self.active_days[dt_to_day( self.environment.datetime_utc_to_local(shift.stop))] = True self.preceding_day_worked = len(shifts_objs) > 0
def _calculate(self, consecutive_days_off=False, return_unsolved_model_for_tuning=False, happiness_scoring=False): """Run the calculation""" # Import Guorbi now so server connection doesn't go stale # (importing triggers a server connection) import gurobipy as grb GRB = grb.GRB # For easier constant access m = grb.Model("mobius-%s-role-%s" % (config.ENV, self.environment.role_id)) m.setParam("OutputFlag", False) # Don't print gurobi logs m.setParam("Threads", config.THREADS) # Add Timeout on happiness scoring. if happiness_scoring: m.setParam("TimeLimit", config.HAPPY_CALCULATION_TIMEOUT) # Try loading a tuning file if we're not tuning if not return_unsolved_model_for_tuning: try: m.read(tune_file) logger.info("Loaded tuned model") except: logger.info("No tune file found") # Create objective - which is basically happiness minus penalties obj = grb.LinExpr() # Whether worker is assigned to shift assignments = {} unassigned = {} for e in self.employees: logger.debug("Building shifts for user %s" % e.user_id) for s in self.shifts: assignments[e.user_id, s.shift_id] = m.addVar( vtype=GRB.BINARY, name="user-%s-assigned-shift-%s" % (e.user_id, s.shift_id)) # Only add happiness if we're scoring happiness if happiness_scoring: obj += assignments[e.user_id, s.shift_id] * e.shift_happiness_score(s) # Also add an unassigned shift - and penalize it! unassigned[s.shift_id] = m.addVar(vtype=GRB.BINARY, name="unassigned-shift-%s" % s.shift_id) obj += unassigned[s.shift_id] * config.UNASSIGNED_PENALTY # Helper variables min_week_hours_violation = {} week_minutes_sum = {} day_shifts_sum = {} day_active = {} for e in self.employees: min_week_hours_violation[e.user_id] = m.addVar( vtype=GRB.BINARY, name="user-%s-min-week-hours-violation" % (e.user_id)) week_minutes_sum[e.user_id] = m.addVar( name="user-%s-hours-per-week" % e.user_id) for day in week_day_range(): day_shifts_sum[e.user_id, day] = m.addVar( vtype=GRB.INTEGER, name="user-%s-day-%s-shift-sum" % (e.user_id, day)) day_active[e.user_id, day] = m.addVar( vtype=GRB.BINARY, name="user-%s-day-%s-shift-sum" % (e.user_id, day)) obj += min_week_hours_violation[ e.user_id] * config.MIN_HOURS_VIOLATION_PENALTY m.update() for s in self.shifts: m.addConstr( grb.quicksum(assignments[e.user_id, s.shift_id] for e in self.employees) + unassigned[s.shift_id], GRB.EQUAL, 1) # Allowed shift state transitions for test in self.shifts: # Use index because shifts are sorted! # Iterate through "other" (o) shifts for o in self.shifts: if o.shift_id == test.shift_id: continue # Add min minutes between shifts for allowed overlaps if dt_overlaps( o.start, o.stop, test.start, test.stop + timedelta(minutes=self.environment. min_minutes_between_shifts)): # Add constraint that shift transitions not allowed for e in self.employees: m.addConstr( assignments[e.user_id, test.shift_id] + assignments[e.user_id, o.shift_id], GRB.LESS_EQUAL, 1) # Add consecutive days off constraint # so that workers have a "weekend" - at least 2 consecutive # days off in a week where possible # # The current implementation has us run the model a second # time if this is infeasible, however we should revise it # to be a weighted variable. if consecutive_days_off: for e in self.employees: day_off_sum = grb.LinExpr() previous_day_name = None for day in week_day_range(self.environment.day_week_starts): if not previous_day_name: # It's the first loop if not e.preceding_day_worked: # if they didn't work the day before, then not # working the first day is consec days off day_off_sum += (1 - day_active[e.user_id, day]) else: # We're in the loop not on first day day_off_sum += (1 - day_active[e.user_id, day]) * ( 1 - day_active[e.user_id, previous_day_name]) previous_day_name = day # We now have built the LinExpr. It needs to be >= 1 # (for at least 1 set of consec days off) m.addConstr(day_off_sum, GRB.GREATER_EQUAL, 1) # Availability constraints for e in self.employees: for s in self.shifts: if not e.available_to_work(s): logger.debug("User %s unavailable to work shift %s" % (e.user_id, s.shift_id)) m.addConstr(assignments[e.user_id, s.shift_id], GRB.EQUAL, 0) # Limit employee hours per workweek for e in self.employees: # The running total of shifts is equal to the helper variable m.addConstr( sum([ s.total_minutes() * assignments[e.user_id, s.shift_id] for s in self.shifts ]), GRB.EQUAL, week_minutes_sum[e.user_id]) # The total minutes an employee works in a week is less than or equal to their max m.addConstr(week_minutes_sum[e.user_id], GRB.LESS_EQUAL, e.max_hours_per_workweek * MINUTES_PER_HOUR) # A worker must work at least their min hours per week. # Violation causes a penalty. # NOTE - once the min is violated, we don't say "try to get as close as possible" - # we stop unassigned shifts, but if you violate min then you're not guaranteed anything m.addConstr( week_minutes_sum[e.user_id], GRB.GREATER_EQUAL, e.min_hours_per_workweek * MINUTES_PER_HOUR * (1 - min_week_hours_violation[e.user_id])) for day in week_day_range(): m.addSOS(GRB.SOS_TYPE1, [ day_shifts_sum[e.user_id, day], day_active[e.user_id, day] ]) m.addConstr( day_shifts_sum[e.user_id, day], GRB.EQUAL, grb.quicksum([ assignments[e.user_id, s.shift_id] for s in self.shifts if ((dt_to_day(s.start) == day) or ( dt_to_day(s.stop) == day and s.stop <= self.environment.stop)) ])) m.addConstr( day_shifts_sum[e.user_id, day] + day_active[e.user_id, day], GRB.GREATER_EQUAL, 1) # Limit employee hours per workday workday_start = deepcopy(self.environment.start) while workday_start < self.environment.stop: for e in self.employees: # Look for minutes of overlap workday_stop = workday_start + timedelta(days=1) m.addConstr( sum([ s.minutes_overlap(start=workday_start, stop=workday_stop) * assignments[e.user_id, s.shift_id] for s in self.shifts if dt_overlaps( s.start, s.stop, workday_start, workday_stop) ]), GRB.LESS_EQUAL, self.environment.max_minutes_per_workday) workday_start += timedelta(days=1) m.update() m.setObjective(obj) m.modelSense = GRB.MAXIMIZE # Make something people love! if return_unsolved_model_for_tuning: return m m.optimize() if m.status != GRB.status.OPTIMAL: logger.info("Calculation failed - gurobi status code %s" % m.status) raise Exception("Calculation failed") logger.info("Optimized! objective: %s" % m.objVal) for e in self.employees: if min_week_hours_violation[e.user_id].x > .5: logger.info( "User %s unable to meet min hours for week (hours: %s, min: %s)" % (e.user_id, 1.0 * week_minutes_sum[e.user_id].x / MINUTES_PER_HOUR, e.min_hours_per_workweek)) for s in self.shifts: if assignments[e.user_id, s.shift_id].x > .5: logger.info("User %s assigned shift %s" % (e.user_id, s.shift_id)) s.user_id = e.user_id logger.info( "%s shifts of %s still unsassigned" % (len([s for s in self.shifts if s.user_id == 0]), len(self.shifts)))