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,