コード例 #1
0
    def test_preferences_filtered_by_availability(self):
        # Overwrite preferences to always be 1
        for day in week_day_range():
            self.employee_attributes["preferences"][day] = [1] * HOURS_PER_DAY

        self.create_employee()

        # And now check that the preferences are filtered
        for day in week_day_range():
            for t in range(HOURS_PER_DAY):
                assert self.employee.preferences[day][
                    t] <= self.employee.availability[day][t]
コード例 #2
0
    def test_all_preference_alpha_beta_values(self):
        """ If we prefer all the time, alpha and beta should be zero"""
        for day in week_day_range():
            self.employee_attributes["preferences"][day] = [1] * 24

        self.create_employee()
        assert self.employee.alpha == 0
        assert self.employee.beta == 0
コード例 #3
0
    def _filter_preferences(self):
        raw_prefs = deepcopy(self.preferences)
        processed_prefs = {}
        for day in week_day_range():
            # Dot product so that preference is only valid when available
            processed_prefs[day] = [
                a * b for a, b in zip(self.availability[day], raw_prefs[day])
            ]

        self.preferences = processed_prefs
コード例 #4
0
def test_days_of_week_returns_correct_days_of_week_order():
    expected = ["wednesday",
                "thursday",
                "friday",
                "saturday",
                "sunday",
                "monday",
                "tuesday", ]
    actual = week_day_range(expected[0])
    assert actual == expected
コード例 #5
0
    def _set_alpha_beta(self):
        """ Calculate the alpha and beta values"""
        # (Details in docs)

        # Sum availability and preferences
        sum_availability = 0
        sum_preferences = 0

        for day in week_day_range():
            sum_availability += sum(self.availability[day])
            sum_preferences += sum(self.preferences[day])

        if sum_preferences == sum_availability or sum_preferences == 0 or sum_availability == 0:
            self.alpha = 0
            self.beta = 0
            return

        # Need to force float

        self.alpha = 1.0 * (sum_availability -
                            sum_preferences) / sum_availability

        self.beta = 1.0 * sum_preferences / sum_availability
コード例 #6
0
def test_courier_data():
    env_data = {
        "organization_id": 7,
        "location_id": 8,
        "role_id": 4,
        "schedule_id": 9,
        "tz_string": "America/Los_Angeles",
        "start": "2015-09-14T00:00:00",
        "stop": "2015-09-21T00:00:00",
        "day_week_starts": "monday",
        "min_minutes_per_workday": 60 * 5,
        "max_minutes_per_workday": 60 * 8,
        "min_minutes_between_shifts": 60 * 14,
        "max_consecutive_workdays": 6,
    }

    env = Environment(**env_data)

    # Employee default
    e_default = {
        "user_id": 1,
        "min_hours_per_workweek": 20,
        "max_hours_per_workweek": 29.5,
        "preceding_day_worked": True,
        "preceding_days_worked_streak": 3,
        "existing_shifts": [],  # TODO - should probably add one fixed
        "time_off_requests": [],
        "preferences": {
            "monday": [0] * 24,
            "tuesday": [1] * 24,
            "wednesday":
            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
            "thursday": [0] * 24,
            "friday": [1] * 24,
            "saturday": [1] * 24,
            "sunday": [0] * 24,
        },
        "working_hours": {
            "monday": [1] * 24,
            "tuesday": [1] * 24,
            "wednesday": [1] * 24,
            "thursday": [1] * 24,
            "friday": [1] * 24,
            "saturday": [1] * 24,
            "sunday": [1] * 24,
        },
        "environment": env,
    }

    employees = []

    cameron = deepcopy(e_default)
    cameron["user_id"] = 2
    cameron["preferences"]["thursday"] = [1] * 24
    cameron["preceding_days_worked_streak"] = 5
    employees.append(Employee(**cameron))

    cha = deepcopy(e_default)
    cha["user_id"] = 3
    employees.append(Employee(**cha))

    julian = deepcopy(e_default)
    julian["user_id"] = 4
    julian["min_hours_per_workweek"] = 30
    julian["max_hours_per_workweek"] = 40
    julian["preferences"]["monday"] = julian["preferences"]["wednesday"]
    julian["working_hours"]["sunday"] = julian["preferences"]["wednesday"]
    employees.append(Employee(**julian))

    # Test one person who can't work all hours
    violates_min_hours = deepcopy(e_default)
    violates_min_hours["user_id"] = 188
    violates_min_hours["min_hours_per_workweek"] = 30
    violates_min_hours["max_hours_per_workweek"] = 40
    # We set no availability so it triggers a min hours violation
    for day in week_day_range():
        violates_min_hours["working_hours"][day] = [0] * 24
    employees.append(Employee(**violates_min_hours))

    # and just auto generate 10 more
    for user_id in range(5, 13):
        e = deepcopy(e_default)
        e["user_id"] = user_id
        employees.append(Employee(**e))

    # Start/stop tuples
    start_stops = [
        ("2015-09-14T08:00:00", "2015-09-14T13:00:00"),
        ("2015-09-14T08:00:00", "2015-09-14T12:00:00"),
        ("2015-09-14T08:00:00", "2015-09-14T11:00:00"),
        ("2015-09-14T08:00:00", "2015-09-14T14:00:00"),
        ("2015-09-14T08:00:00", "2015-09-14T13:00:00"),
        ("2015-09-14T11:00:00", "2015-09-14T15:00:00"),
        ("2015-09-14T12:00:00", "2015-09-14T17:00:00"),
        ("2015-09-14T13:00:00", "2015-09-14T21:00:00"),
        ("2015-09-14T13:00:00", "2015-09-14T21:00:00"),
        ("2015-09-14T14:00:00", "2015-09-14T21:00:00"),
        ("2015-09-14T15:00:00", "2015-09-14T21:00:00"),
        ("2015-09-14T17:00:00", "2015-09-14T21:00:00"),
        ("2015-09-15T08:00:00", "2015-09-15T14:00:00"),
        ("2015-09-15T08:00:00", "2015-09-15T12:00:00"),
        ("2015-09-15T08:00:00", "2015-09-15T13:00:00"),
        ("2015-09-15T08:00:00", "2015-09-15T11:00:00"),
        ("2015-09-15T08:00:00", "2015-09-15T16:00:00"),
        ("2015-09-15T11:00:00", "2015-09-15T16:00:00"),
        ("2015-09-15T12:00:00", "2015-09-15T17:00:00"),
        ("2015-09-15T13:00:00", "2015-09-15T21:00:00"),
        ("2015-09-15T14:00:00", "2015-09-15T21:00:00"),
        ("2015-09-15T16:00:00", "2015-09-15T21:00:00"),
        ("2015-09-15T16:00:00", "2015-09-15T21:00:00"),
        ("2015-09-15T17:00:00", "2015-09-15T21:00:00"),
        ("2015-09-16T08:00:00", "2015-09-16T14:00:00"),
        ("2015-09-16T08:00:00", "2015-09-16T13:00:00"),
        ("2015-09-16T08:00:00", "2015-09-16T13:00:00"),
        ("2015-09-16T08:00:00", "2015-09-16T12:00:00"),
        ("2015-09-16T08:00:00", "2015-09-16T14:00:00"),
        ("2015-09-16T12:00:00", "2015-09-16T16:00:00"),
        ("2015-09-16T13:00:00", "2015-09-16T21:00:00"),
        ("2015-09-16T13:00:00", "2015-09-16T17:00:00"),
        ("2015-09-16T14:00:00", "2015-09-16T21:00:00"),
        ("2015-09-16T14:00:00", "2015-09-16T21:00:00"),
        ("2015-09-16T16:00:00", "2015-09-16T21:00:00"),
        ("2015-09-16T17:00:00", "2015-09-16T21:00:00"),
        ("2015-09-17T08:00:00", "2015-09-17T12:00:00"),
        ("2015-09-17T08:00:00", "2015-09-17T13:00:00"),
        ("2015-09-17T08:00:00", "2015-09-17T15:00:00"),
        ("2015-09-17T08:00:00", "2015-09-17T12:00:00"),
        ("2015-09-17T08:00:00", "2015-09-17T15:00:00"),
        ("2015-09-17T11:00:00", "2015-09-17T16:00:00"),
        ("2015-09-17T12:00:00", "2015-09-17T16:00:00"),
        ("2015-09-17T12:00:00", "2015-09-17T17:00:00"),
        ("2015-09-17T15:00:00", "2015-09-17T19:00:00"),
        ("2015-09-17T15:00:00", "2015-09-17T21:00:00"),
        ("2015-09-17T16:00:00", "2015-09-17T21:00:00"),
        ("2015-09-17T16:00:00", "2015-09-17T21:00:00"),
        ("2015-09-17T17:00:00", "2015-09-17T21:00:00"),
        ("2015-09-17T17:00:00", "2015-09-17T21:00:00"),
        ("2015-09-18T08:00:00", "2015-09-18T11:00:00"),
        ("2015-09-18T08:00:00", "2015-09-18T13:00:00"),
        ("2015-09-18T08:00:00", "2015-09-18T13:00:00"),
        ("2015-09-18T08:00:00", "2015-09-18T12:00:00"),
        ("2015-09-18T08:00:00", "2015-09-18T14:00:00"),
        ("2015-09-18T11:00:00", "2015-09-18T16:00:00"),
        ("2015-09-18T12:00:00", "2015-09-18T17:00:00"),
        ("2015-09-18T13:00:00", "2015-09-18T17:00:00"),
        ("2015-09-18T13:00:00", "2015-09-18T21:00:00"),
        ("2015-09-18T14:00:00", "2015-09-18T21:00:00"),
        ("2015-09-18T16:00:00", "2015-09-18T21:00:00"),
        ("2015-09-18T17:00:00", "2015-09-18T21:00:00"),
        ("2015-09-18T17:00:00", "2015-09-18T21:00:00"),
        ("2015-09-19T08:00:00", "2015-09-19T14:00:00"),
        ("2015-09-19T08:00:00", "2015-09-19T12:00:00"),
        ("2015-09-19T08:00:00", "2015-09-19T13:00:00"),
        ("2015-09-19T09:00:00", "2015-09-19T19:00:00"),
        ("2015-09-19T10:00:00", "2015-09-19T16:00:00"),
        ("2015-09-19T12:00:00", "2015-09-19T17:00:00"),
        ("2015-09-19T13:00:00", "2015-09-19T21:00:00"),
        ("2015-09-19T14:00:00", "2015-09-19T21:00:00"),
        ("2015-09-19T16:00:00", "2015-09-19T21:00:00"),
        ("2015-09-19T17:00:00", "2015-09-19T21:00:00"),
        ("2015-09-20T08:00:00", "2015-09-20T13:00:00"),
        ("2015-09-20T08:00:00", "2015-09-20T14:00:00"),
        ("2015-09-20T08:00:00", "2015-09-20T12:00:00"),
        ("2015-09-20T09:00:00", "2015-09-20T14:00:00"),
        ("2015-09-20T10:00:00", "2015-09-20T21:00:00"),
        ("2015-09-20T12:00:00", "2015-09-20T17:00:00"),
        ("2015-09-20T13:00:00", "2015-09-20T19:00:00"),
        ("2015-09-20T14:00:00", "2015-09-20T21:00:00"),
        ("2015-09-20T14:00:00", "2015-09-20T21:00:00"),
        ("2015-09-20T17:00:00", "2015-09-20T21:00:00"),
    ]

    shifts = []
    shift_id = 0
    for (start, stop) in start_stops:
        shift_id += 1
        s = {
            "id": shift_id,
            "user_id": 0,
            "start": start,
            "stop": stop,
        }

        shifts.append(Shift(s))

    a = Assign(env, employees, shifts)
    a.calculate()
コード例 #7
0
 def _build_active_days(self):
     """Build which days the person is *already* working from fixed shifts"""
     self.active_days = {}
     for day in week_day_range():
         self.active_days[day] = False
コード例 #8
0
ファイル: assign.py プロジェクト: yehancha/mobius-assignment
    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)))
コード例 #9
0
def test_week_day_range_throws_error_for_invalid_day():
    with pytest.raises(ValueError):
        week_day_range("miercoles")
コード例 #10
0
def test_week_day_range_succeeds_with_no_input():
    week_day_range()