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]
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
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
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
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
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()
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
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)))
def test_week_day_range_throws_error_for_invalid_day(): with pytest.raises(ValueError): week_day_range("miercoles")
def test_week_day_range_succeeds_with_no_input(): week_day_range()