Example #1
0
    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))
Example #2
0
    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
Example #3
0
 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()
Example #4
0
    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)
Example #5
0
    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()
Example #6
0
    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")
Example #7
0
    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
Example #8
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
Example #9
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)))