diff --git a/.travis.yml b/.travis.yml index c32269e..364e5a6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,12 +6,15 @@ python: - 3.9 os: - linux +env: + global: + - COV_THRESHOLD=95 before_install: - python --version - pip install -U pip install: - pip install -r dev-requirements.txt script: - - pytest pyworkforce/ --verbose --color=yes --assert=plain --cov-fail-under=95 --cov-config=.coveragerc --cov=./ + - pytest pyworkforce/ --verbose --color=yes --assert=plain --cov-fail-under=$COV_THRESHOLD --cov-config=.coveragerc --cov=./ after_success: - codecov diff --git a/README.md b/README.md index 6551de3..92fac2c 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,20 @@ and custom modules pyworkforce currently includes: [Queue Systems](./pyworkforce/queuing): -- **queing.ErlangC:** Find the number of positions required to attend incoming traffic to a constant rate and infinite queue length and no dropout. +- **queing.ErlangC:** Find the number of positions required to attend incoming traffic to a constant rate, infinite queue length and no dropout. [Shifts](./pyworkforce/shifts): -- **shifts.MinAbsDifference:** Find the number of resources to schedule in a shift, based in the number of required positions per time interval (found for example using [queing.ErlangC](./pyworkforce/queuing/erlang.py)), maximum capacity restrictions and static shifts coverage.
-This module finds the "optimal" assignation by minimizing the total absolute differences between required resources per interval, against the scheduled resources found by the solver. + +It finds the number of resources to schedule in a shift, based in the number of required positions per time interval (found for example using [queing.ErlangC](./pyworkforce/queuing/erlang.py)), maximum capacity restrictions and static shifts coverage.
+- **shifts.MinAbsDifference:** This module finds the "optimal" assignation by minimizing the total absolute differences between required resources per interval, against the scheduled resources found by the solver. +- **shifts.MinRequiredResources**: This module finds the "optimal" assignation by minimizing the total weighted amount of scheduled resources (optionally weighted by shift cost), it ensures that in all intervals, there are + never less resources shifted that the ones required per period. # Usage: -For complete list and details of examples go to the -[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples) +Install pyworkforce -install pyworkforce +It's advised to install pyworkforce using a virtual env, inside the env use: ``` pip install pyworkforce @@ -32,6 +34,9 @@ pip install pyworkforce If you are having troubles with or-tools installation, check the [or-tools guide](https://github.com/google/or-tools#installation) +For complete list and details of examples go to the +[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples) + ### Queue systems: #### Example: @@ -57,7 +62,7 @@ Output: #### Example: ```python -from pyworkforce.shifts import MinAbsDifference +from pyworkforce.shifts import MinAbsDifference, MinRequiredResources # Rows are the days, each entry of a row, is number of positions required at an hour of the day (24). required_resources = [ @@ -72,27 +77,53 @@ shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0 "Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]} -scheduler = MinAbsDifference(num_days=2, - periods=24, - shifts_coverage=shifts_coverage, - required_resources=required_resources, - max_period_concurrency=25, - max_shift_concurrency=20) +# Method One +difference_scheduler = MinAbsDifference(num_days=2, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + max_period_concurrency=27, + max_shift_concurrency=25) + +difference_solution = difference_scheduler.solve() + +# Method Two + +requirements_scheduler = MinRequiredResources(num_days=2, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + max_period_concurrency=27, + max_shift_concurrency=25) + +requirements_solution = requirements_scheduler.solve() + +print("difference_solution :", difference_solution) -solution = scheduler.solve() -print("solution :", solution) +print("requirements_solution :", requirements_solution) ``` Output: ``` ->> solution: {'status': 'OPTIMAL', - 'cost': 157.0, - 'resources_shifts': [{'day': 0, 'shift': 'Morning', 'resources': 8}, - {'day': 0, 'shift': 'Afternoon', 'resources': 11}, - {'day': 0, 'shift': 'Night', 'resources': 9}, - {'day': 0, 'shift': 'Mixed', 'resources': 1}, - {'day': 1, 'shift': 'Morning', 'resources': 13}, - {'day': 1, 'shift': 'Afternoon', 'resources': 17}, - {'day': 1, 'shift': 'Night', 'resources': 13}, - {'day': 1, 'shift': 'Mixed', 'resources': 0}] - } +>> difference_solution: {'status': 'OPTIMAL', + 'cost': 157.0, + 'resources_shifts': [{'day': 0, 'shift': 'Morning', 'resources': 8}, + {'day': 0, 'shift': 'Afternoon', 'resources': 11}, + {'day': 0, 'shift': 'Night', 'resources': 9}, + {'day': 0, 'shift': 'Mixed', 'resources': 1}, + {'day': 1, 'shift': 'Morning', 'resources': 13}, + {'day': 1, 'shift': 'Afternoon', 'resources': 17}, + {'day': 1, 'shift': 'Night', 'resources': 13}, + {'day': 1, 'shift': 'Mixed', 'resources': 0}] + } + +>> requirements_solution: {'status': 'OPTIMAL', + 'cost': 113.0, + 'resources_shifts': [{'day': 0, 'shift': 'Morning', 'resources': 15}, + {'day': 0, 'shift': 'Afternoon', 'resources': 13}, + {'day': 0, 'shift': 'Night', 'resources': 19}, + {'day': 0, 'shift': 'Mixed', 'resources': 3}, + {'day': 1, 'shift': 'Morning', 'resources': 20}, + {'day': 1, 'shift': 'Afternoon', 'resources': 20}, + {'day': 1, 'shift': 'Night', 'resources': 23}, + {'day': 1, 'shift': 'Mixed', 'resources': 0}]} ``` \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt index c548b95..3cc7d83 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,3 +4,4 @@ pytest-cov==2.11.1 twine==3.3.0 numpy>=1.18.1 ortools>=7.8.7959 +pandas>=1.0.0 diff --git a/examples/shifts/min_abs_difference.py b/examples/shifts/min_abs_difference.py index 20fa433..73c6c4d 100644 --- a/examples/shifts/min_abs_difference.py +++ b/examples/shifts/min_abs_difference.py @@ -28,8 +28,8 @@ periods=24, shifts_coverage=shifts_coverage, required_resources=required_resources, - max_period_concurrency=25, - max_shift_concurrency=20) + max_period_concurrency=27, + max_shift_concurrency=25) solution = scheduler.solve() print(solution) diff --git a/examples/shifts/min_required_resources.py b/examples/shifts/min_required_resources.py new file mode 100644 index 0000000..adba9f7 --- /dev/null +++ b/examples/shifts/min_required_resources.py @@ -0,0 +1,40 @@ +""" +Requirement: Find the number of workers needed to schedule per shift in a production plant for the next 2 days with the + following conditions: + * There is a number of required persons per hour and day given in the matrix "required_resources" + * There are 4 available shifts called "Morning", "Afternoon", "Night", "Mixed"; their start and end hour is + determined in the dictionary "shifts_coverage", 1 meaning the shift is active at that hour, 0 otherwise + * The number of required workers per day and period (hour) is determined in the matrix "required_resources" + * The maximum number of workers that can be shifted simultaneously at any hour is 25, due plat capacity restrictions + * The maximum number of workers that can be shifted in a same shift, is 20 +""" + +from pyworkforce.shifts import MinRequiredResources + +# Columns are an hour of the day, rows are the days +required_resources = [ + [9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7], + [13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8] +] + +# Each entry of a shift, is an hour of the day (24 columns) +shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], + "Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], + "Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]} + + +# The cost of shifting a resource if each shift, if present, solver will minimize the total cost +cost_dict = {"Morning": 8, "Afternoon": 8, "Night": 10, "Mixed": 7} + +scheduler = MinRequiredResources(num_days=2, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + cost_dict=cost_dict, + max_period_concurrency=25, + max_shift_concurrency=25) + +solution = scheduler.solve() + +print(solution) diff --git a/pyworkforce/shifts/__init__.py b/pyworkforce/shifts/__init__.py index 48cd4c3..24b58aa 100644 --- a/pyworkforce/shifts/__init__.py +++ b/pyworkforce/shifts/__init__.py @@ -1,3 +1,3 @@ -from pyworkforce.shifts.shifts_selection import MinAbsDifference +from pyworkforce.shifts.shifts_selection import MinAbsDifference, MinRequiredResources -__all__ = ["MinAbsDifference"] +__all__ = ["MinAbsDifference", "MinRequiredResources"] diff --git a/pyworkforce/shifts/base.py b/pyworkforce/shifts/base.py new file mode 100644 index 0000000..40899be --- /dev/null +++ b/pyworkforce/shifts/base.py @@ -0,0 +1,51 @@ +from ortools.sat.python import cp_model +from pyworkforce.shifts.utils import check_positive_integer, check_positive_float + + +class BaseShiftScheduler: + def __init__(self, num_days: int, + periods: int, + shifts_coverage: dict, + required_resources: list, + max_period_concurrency: int, + max_shift_concurrency: int, + max_search_time: float = 240.0, + num_search_workers=4): + + """ + Base class to solve the following schedule problem: + + Its required to find the optimal number of resources (agents, operators, doctors, etc) to allocate + in a shift, based on a pre-defined requirement of number of resources per period of the day (periods of hours, + half-hour, etc) + + :param num_days: Number of days needed to schedule + :param periods: Number of working periods in a day + :param shifts_coverage: dict with structure {"shift_name": "shift_array"} where "shift_array" is an array of size [periods] (p), 1 if shift covers period p, 0 otherwise + :param max_period_concurrency: Maximum resources allowed to shift in any period and day + :param required_resources: Array of size [days, periods] + :param max_shift_concurrency: Number of maximum allowed resources in a same shift + :param max_search_time: Maximum time in seconds to search for a solution + :param num_search_workers: Number of workers to search a solution + """ + + is_valid_num_days = check_positive_integer("num_days", num_days) + is_valid_periods = check_positive_integer("periods", periods) + is_valid_max_period_concurrency = check_positive_integer("max_period_concurrency", max_period_concurrency) + is_valid_max_shift_concurrency = check_positive_integer("max_shift_concurrency", max_shift_concurrency) + is_valid_max_search_time = check_positive_float("max_search_time", max_search_time) + is_valid_num_search_workers = check_positive_integer("num_search_workers", num_search_workers) + + self.num_days = num_days + self.shifts = list(shifts_coverage.keys()) + self.num_shifts = len(self.shifts) + self.num_periods = periods + self.shifts_coverage_matrix = list(shifts_coverage.values()) + self.max_shift_concurrency = max_shift_concurrency + self.max_period_concurrency = max_period_concurrency + self.required_resources = required_resources + self.max_search_time = max_search_time + self.num_search_workers = num_search_workers + self.solver = cp_model.CpSolver() + self.transposed_shifts_coverage = None + self.status = None diff --git a/pyworkforce/shifts/shifts_selection.py b/pyworkforce/shifts/shifts_selection.py index 24d380b..e688262 100644 --- a/pyworkforce/shifts/shifts_selection.py +++ b/pyworkforce/shifts/shifts_selection.py @@ -1,9 +1,10 @@ import numpy as np +import pandas as pd from ortools.sat.python import cp_model -from pyworkforce.shifts.utils import check_positive_integer, check_positive_float +from pyworkforce.shifts.base import BaseShiftScheduler -class MinAbsDifference: +class MinAbsDifference(BaseShiftScheduler): def __init__(self, num_days: int, periods: int, shifts_coverage: dict, @@ -14,46 +15,18 @@ def __init__(self, num_days: int, num_search_workers=4, *args, **kwargs): """ - Solves the following schedule problem: - - Its required to find the optimal number of resources (agents, operators, doctors, etc) to allocate - in a shift, based on a pre-defined requirement of number of resources per period of the day (periods of hours, - half-hour, etc) - - The "optimal" criteria, is defined as the amount of resources per shifts that minimize the total absolute - difference, between the required resources per period and the actual shifted by the solver - - - :param num_days: Number of days needed to schedule - :param periods: Number of working periods in a day - :param shifts_coverage: dict with structure {"shift_name": "shift_array"} where "shift_array" is an array of size [periods] (p), 1 if shift covers period p, 0 otherwise - :param max_period_concurrency: Maximum resources allowed to shift in any period and day - :param required_resources: Array of size [days, periods] - :param max_shift_concurrency: Number of maximum allowed resources in a same shift - :param max_search_time: Maximum time in seconds to search for a solution - :param num_search_workers: Number of workers to search a solution + The "optimal" criteria, is defined as the amount of resources per shifts that minimize the total absolute + difference, between the required resources per period and the actual shifted by the solver """ - is_valid_num_days = check_positive_integer("num_days", num_days) - is_valid_periods = check_positive_integer("periods", periods) - is_valid_max_period_concurrency = check_positive_integer("max_period_concurrency", max_period_concurrency) - is_valid_max_shift_concurrency = check_positive_integer("max_shift_concurrency", max_shift_concurrency) - is_valid_max_search_time = check_positive_float("max_search_time", max_search_time) - is_valid_num_search_workers = check_positive_integer("num_search_workers", num_search_workers) - - self.num_days = num_days - self.shifts = list(shifts_coverage.keys()) - self.num_shifts = len(self.shifts) - self.num_periods = periods - self.shifts_coverage_matrix = list(shifts_coverage.values()) - self.max_shift_concurrency = max_shift_concurrency - self.max_period_concurrency = max_period_concurrency - self.required_resources = required_resources - self.max_search_time = max_search_time - self.num_search_workers = num_search_workers - self.solver = cp_model.CpSolver() - self.transposed_shifts_coverage = None - self.status = None + super().__init__(num_days, + periods, + shifts_coverage, + required_resources, + max_period_concurrency, + max_shift_concurrency, + max_search_time, + num_search_workers) def solve(self): sch_model = cp_model.CpModel() @@ -67,7 +40,7 @@ def solve(self): # Resources for d in range(self.num_days): for s in range(self.num_shifts): - resources[d][s] = sch_model.NewIntVar(0, self.max_shift_concurrency, f'agents_d{d}s{s}') + resources[d][s] = sch_model.NewIntVar(0, self.max_shift_concurrency, f'resources_d{d}s{s}') for d in range(self.num_days): for p in range(self.num_periods): @@ -122,3 +95,97 @@ def solve(self): "resources_shifts": [{'day': -1, 'shift': 'Unknown', 'resources': -1}]} return solution + + +class MinRequiredResources(BaseShiftScheduler): + def __init__(self, num_days: int, + periods: int, + shifts_coverage: dict, + required_resources: list, + max_period_concurrency: int, + max_shift_concurrency: int, + cost_dict: dict = None, + max_search_time: float = 240.0, + num_search_workers: int = 4, + *args, **kwargs): + """ + The "optimal" criteria, is defined as minimum weighted amount of resources (by optional shift cost), + that ensures that there are never less resources shifted that the ones required per period + + :param cost_dict: dict of form {shift: cost_value}, where shift must be the same options listed in the + shifts_coverage matrix and they must be all integers + """ + + super().__init__(num_days, + periods, + shifts_coverage, + required_resources, + max_period_concurrency, + max_shift_concurrency, + max_search_time, + num_search_workers) + + if cost_dict is None: + self.cost_dict = dict.fromkeys(self.shifts, 1) + else: + self.cost_dict = cost_dict + + if set(sorted(self.shifts)) == set(sorted(list(self.cost_dict.keys()))): + self.df_cost_matrix = pd.DataFrame.from_records([self.cost_dict]) + else: + raise KeyError('cost_dict must have the same keys as shifts_coverage') + + def solve(self): + sch_model = cp_model.CpModel() + + # Resources: Number of resources assigned in day d to shift s + resources = np.empty(shape=(self.num_days, self.num_shifts), dtype='object') + + # Resources + for d in range(self.num_days): + for s in range(self.num_shifts): + resources[d][s] = sch_model.NewIntVar(0, self.max_shift_concurrency, f'resources_d{d}s{s}') + + # Constrains + + # Total programmed resources in day d and period p, must be greater or equals that required resources in d, p + for d in range(self.num_days): + for p in range(self.num_periods): + sch_model.Add(sum(resources[d][s] * self.shifts_coverage_matrix[s][p] + for s in range(self.num_shifts)) >= self.required_resources[d][p]) + + # Total programmed resources, must be less or equals to max_period_concurrency, for each day and period + for d in range(self.num_days): + for p in range(self.num_periods): + sch_model.Add( + sum(resources[d][s] * self.shifts_coverage_matrix[s][p] + for s in range(self.num_shifts)) <= self.max_period_concurrency) + + # Objective Function: Minimize the total shifted resources + sch_model.Minimize(sum(resources[d][s] * self.df_cost_matrix[self.shifts[s]].item() + for d in range(self.num_days) + for s in range(self.num_shifts))) + + self.solver.parameters.max_time_in_seconds = self.max_search_time + self.solver.num_search_workers = self.num_search_workers + + self.status = self.solver.Solve(sch_model) + + if self.status in [cp_model.OPTIMAL, cp_model.FEASIBLE]: + resources_shifts = [] + for d in range(self.num_days): + for s in range(self.num_shifts): + resources_shifts.append({ + "day": d, + "shift": self.shifts[s], + "resources": self.solver.Value(resources[d][s])}) + + solution = {"status": self.solver.StatusName(self.status), + "cost": self.solver.ObjectiveValue(), + "resources_shifts": resources_shifts} + else: + solution = {"status": self.solver.StatusName(self.status), + "cost": -1, + "resources_shifts": [{'day': -1, 'shift': 'Unknown', 'resources': -1}]} + + return solution diff --git a/pyworkforce/shifts/tests/test_shifts.py b/pyworkforce/shifts/tests/test_shifts.py index 8e74bb3..f3436f7 100644 --- a/pyworkforce/shifts/tests/test_shifts.py +++ b/pyworkforce/shifts/tests/test_shifts.py @@ -1,4 +1,5 @@ -from pyworkforce.shifts import MinAbsDifference +from pyworkforce.shifts import MinAbsDifference, MinRequiredResources +import pytest def test_min_abs_difference_schedule(): @@ -59,3 +60,121 @@ def test_infeasible_min_abs_difference_schedule(): assert solution['resources_shifts'][0]['day'] == -1 assert solution['resources_shifts'][0]['shift'] == 'Unknown' assert solution['resources_shifts'][0]['resources'] == -1 + + +def test_min_required_resources_schedule(): + required_resources = [ + [9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7], + [13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8] + ] + shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], + "Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], + "Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]} + + num_days = 2 + + scheduler = MinRequiredResources(num_days=num_days, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + max_period_concurrency=25, + max_shift_concurrency=25) + + solution = scheduler.solve() + + assert solution['status'] == 'OPTIMAL' + assert 'cost' in solution + assert 'resources_shifts' in solution + assert len(solution['resources_shifts']) == num_days * len(shifts_coverage) + for i in range(num_days * len(shifts_coverage)): + assert solution['resources_shifts'][i]['resources'] >= 0 + + +def test_cost_min_required_resources_schedule(): + required_resources = [ + [9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7], + [13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8] + ] + shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], + "Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], + "Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]} + + cost_dict = {"Morning": 8, "Afternoon": 8, "Night": 10, "Mixed": 7} + + num_days = 2 + + scheduler = MinRequiredResources(num_days=num_days, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + cost_dict=cost_dict, + max_period_concurrency=25, + max_shift_concurrency=25) + + solution = scheduler.solve() + + assert solution['status'] == 'OPTIMAL' + assert 'cost' in solution + assert 'resources_shifts' in solution + assert len(solution['resources_shifts']) == num_days * len(shifts_coverage) + for i in range(num_days * len(shifts_coverage)): + assert solution['resources_shifts'][i]['resources'] >= 0 + + +def test_wrong_cost_min_required_resources_schedule(): + required_resources = [ + [9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7], + [13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8] + ] + shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], + "Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], + "Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]} + + cost_dict = {"Morning": 8, "Night": 10, "Mixed": 7} + + num_days = 2 + with pytest.raises(Exception) as excinfo: + scheduler = MinRequiredResources(num_days=num_days, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + cost_dict=cost_dict, + max_period_concurrency=25, + max_shift_concurrency=25) + + solution = scheduler.solve() + assert str(excinfo.value) == "cost_dict must have the same keys as shifts_coverage" + + +def test_infeasible_min_required_resources_schedule(): + required_resources = [ + [9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7], + [13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8] + ] + shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], + "Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], + "Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]} + + num_days = 2 + + scheduler = MinRequiredResources(num_days=num_days, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + max_period_concurrency=25, + max_shift_concurrency=20) + + solution = scheduler.solve() + + assert solution['status'] == 'INFEASIBLE' + assert 'cost' in solution + assert 'resources_shifts' in solution + assert solution['cost'] == -1 + assert len(solution['resources_shifts']) == 1 + assert solution['resources_shifts'][0]['day'] == -1 + assert solution['resources_shifts'][0]['shift'] == 'Unknown' + assert solution['resources_shifts'][0]['resources'] == -1 diff --git a/setup.py b/setup.py index f9563fa..33334eb 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="pyworkforce", - version="0.2.2", + version="0.3.0", description="Common tools for workforce management, schedule and optimization problems", long_description=README, long_description_content_type="text/markdown", @@ -28,7 +28,8 @@ packages=find_packages(include=['pyworkforce', 'pyworkforce.*']), install_requires=[ 'numpy>=1.18.1', - 'ortools>=7.8.7959' + 'ortools>=7.8.7959', + 'pandas>=1.0.0' ], python_requires=">=3.6", include_package_data=True,