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_days_worked_streak(self): """See how many days in an row the worker has worked prior to the beginning of this week. """ logger.debug("Fetching preceding day streak for user %s" % self.user_id) # Search up to max_consecutive_workdays - beyond doesn't matter streak = 0 for t in range(self.environment.max_consecutive_workdays): # Build search search_end = self.environment.start - timedelta(days=t) search_start = search_end - timedelta(days=1) shifts_obj = self._get_role_client().get_shifts( start=dt_to_query_str(search_start - timedelta( hours=config.MAX_HOURS_PER_SHIFT)), end=dt_to_query_str(search_end), user_id=self.user_id, ) if len([ s for s in shifts_obj if (Shift(s).start >= search_start) > 0 ]): streak += 1 else: # Streak over! return streak return streak
def _fetch_working_hours(self): """Fetch working hours from the api and set on instance""" logger.debug("Fetching working hours for user %s" % self.user_id) role = self._get_role_client() worker = role.get_worker(self.user_id) self.availability = worker.data.get("working_hours") if not self.availability: self.availability = week_range_all_true()
def _fetch_time_off_requests(self): """Fetch time off requests from the api and return""" logger.debug("Fetching time off requests for user %s" % self.user_id) role = self._get_role_client() worker = role.get_worker(self.user_id) # Get requests from API return worker.get_time_off_requests(start=self.environment.start, end=self.environment.stop)
def _fetch_preferences(self): """Fetch preferences from the api and set on instance""" logger.debug("Fetching preferences for user %s" % self.user_id) try: pref_obj = self._get_role_client().get_schedule( self.environment.schedule_id).get_preference(self.user_id) self.preferences = pref_obj.data.get("preference") except NotFoundException: self.preferences = week_range_all_true() if not self.preferences: self.preferences = week_range_all_true()
def server(self): previous_request_failed = False # Have some built-in retries while True: # Get task try: task = self.client.claim_mobius_task() logger.info("Task received: %s" % task.data) previous_request_failed = False except NotFoundException: logger.debug("No task found. Sleeping.") previous_request_failed = False sleep(config.TASKING_FETCH_INTERVAL_SECONDS) continue except Exception as e: if not previous_request_failed: # retry, but info log it logger.info("Unable to fetch mobius task - retrying") previous_request_failed = True else: logger.error( "Unable to fetch mobius task after previous failure: %s" % e) # Still sleep so we avoid thundering herd sleep(config.TASKING_FETCH_INTERVAL_SECONDS) continue try: self._process_task(task) task.delete() logger.info("Task completed %s" % task.data) except Exception as e: logger.error("Failed schedule %s: %s %s" % (task.data.get("schedule_id"), e, traceback.format_exc())) logger.info("Requeuing schedule %s" % task.data.get("schedule_id")) # self.sched set in process_task self.sched.patch(state=self.REQUEUE_STATE) # Sometimes rebooting Mobius helps with errors. For example, if # a Gurobi connection is drained then it helps to reboot. if config.KILL_ON_ERROR: sleep(config.KILL_DELAY) logger.info("Rebooting to kill container") os.system("shutdown -r now")
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 _fetch_existing_shifts(self): """Look for fixed shifts and other stuff""" logger.debug("Fetching existing shifts for user %s" % self.user_id) self.existing_shifts = [] shifts_obj_raw = self._get_role_client().get_shifts( start=dt_to_query_str(self.environment.start - timedelta(hours=config.MAX_HOURS_PER_SHIFT)), end=dt_to_query_str(self.environment.stop), user_id=self.user_id) shifts_obj = [] for s in shifts_obj_raw: shifts_obj.append(Shift(s)) for s in [s for s in shifts_obj if s.start >= self.environment.start]: logger.info("Found existing shift %s for user %s" % (s.shift_id, self.user_id)) self.existing_shifts.append(s) # Also decrease hours to be scheduled by that self.min_hours_per_workweek -= 1.0 * s.total_minutes( ) / MINUTES_PER_HOUR if self.min_hours_per_workweek < 0: self.min_hours_per_workweek = 0 self.max_hours_per_workweek -= 1.0 * s.total_minutes( ) / MINUTES_PER_HOUR if self.max_hours_per_workweek < 0: self.min_hours_per_workweek = 0 self.max_hours_per_workweek -= 1.0 * s.total_minutes( ) / MINUTES_PER_HOUR if self.max_hours_per_workweek < 0: self.max_hours_per_workweek = 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)))