diff --git a/README.md b/README.md index 8aab049..7c50ae1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # pyworkforce -Common tools for workforce management, schedule and optimization problems. +Common tools for workforce management, schedule and optimization problems built in top of tools like google's ortools +and custom modules +# Usage: +For complete list and details of examples go to the +[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples) ### Queue systems: @@ -11,11 +15,11 @@ under some systems pre-defined parameters and goals. #### Example: ```python -from pyworkforce.queuing.erlang import ErlangC +from pyworkforce.queuing import ErlangC -erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, max_occupancy=0.85, shrinkage=0.3) +erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, shrinkage=0.3) -positions_requirements = erlang.required_positions(service_level=0.8) +positions_requirements = erlang.required_positions(service_level=0.8, max_occupancy=0.85) print("positions_requirements: ", positions_requirements) @@ -25,3 +29,49 @@ print("positions_requirements: ", positions_requirements) 'occupancy': 0.7142857142857143, 'waiting_probability': 0.1741319335950498} ``` + +### Shifts Design + +Find the optimal number of persons to assign to a pre-defined list of shifts, under a requirement of persons per period +of day and capacity restrictions + +#### Example: + +```python +from pyworkforce.shifts import MinAbsDifference + +# Rows are the days, each entry of a row, is an hour of the day (24). +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) +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]} + + +scheduler = MinAbsDifference(num_days=2, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + max_period_concurrency=25, + max_shift_concurrency=20) + +solution = scheduler.solve() +print("solution :", solution) + +>> 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}] + } +``` \ No newline at end of file diff --git a/examples/queing/erlangc.py b/examples/queing/erlangc.py index f171b6e..545c604 100644 --- a/examples/queing/erlangc.py +++ b/examples/queing/erlangc.py @@ -13,16 +13,20 @@ Shrinkage: 30% """ -from pyworkforce.queuing.erlang import ErlangC +from pyworkforce.queuing import ErlangC -erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, max_occupancy=0.85, shrinkage=0.3) +erlang = ErlangC(transactions=100, aht=3, interval=30, asa=20/60, shrinkage=0.3) -positions_requirements = erlang.required_positions(service_level=0.8) +positions_requirements = erlang.required_positions(service_level=0.8, max_occupancy=0.85) print("positions_requirements: ", positions_requirements) achieved_service_level = erlang.service_level(positions=positions_requirements['raw_positions']) print("achieved_service_level: ", achieved_service_level) +achieved_service_level = erlang.service_level(positions=positions_requirements['positions'], + scale_positions=True) +print("achieved_service_level: ", achieved_service_level) + waiting_probability = erlang.waiting_probability(positions=positions_requirements['raw_positions']) print("waiting_probability: ", waiting_probability) diff --git a/examples/shifts/min_abs_difference.py b/examples/shifts/min_abs_difference.py new file mode 100644 index 0000000..20fa433 --- /dev/null +++ b/examples/shifts/min_abs_difference.py @@ -0,0 +1,35 @@ +""" +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 MinAbsDifference + +# 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]} + + +scheduler = MinAbsDifference(num_days=2, + periods=24, + shifts_coverage=shifts_coverage, + required_resources=required_resources, + max_period_concurrency=25, + max_shift_concurrency=20) + +solution = scheduler.solve() +print(solution) diff --git a/pyworkforce/queuing/__init__.py b/pyworkforce/queuing/__init__.py index e69de29..bf59d0d 100644 --- a/pyworkforce/queuing/__init__.py +++ b/pyworkforce/queuing/__init__.py @@ -0,0 +1,3 @@ +from pyworkforce.queuing.erlang import ErlangC + +__all__ = ["ErlangC"] diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 02981e0..24833de 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -2,26 +2,41 @@ class ErlangC: - def __init__(self, transactions: float, asa: float, aht: float, - interval: int = None, shrinkage=0.0, max_occupancy=1.0, + def __init__(self, transactions: float, aht: float, asa: float, + interval: int = None, shrinkage=0.0, **kwargs): """ Computes the number of positions required fo attend a number of transactions in a queue system based on ErlangC Implementation based on: https://lucidmanager.org/data-science/call-centre-workforce-planning-erlang-c-in-r/ - :param transactions: number of total transactions that comes in + :param transactions: number of total transactions that comes in an interval :param aht: average handling time of a transaction (minutes) + :param asa: Required average speed of answer in minutes :param interval: Interval length (minutes) :param shrinkage: Percentage of time that an operator unit is not available - :param max_occupancy: Maximum percentage of time that an attending position can be occupied """ + + if transactions < 0: + raise ValueError("transactions can't be smaller than 0") + + if aht < 0: + raise ValueError("aht can't be smaller than 0") + + if asa < 0: + raise ValueError("asa can't be smaller than 0") + + if interval < 0: + raise ValueError("interval can't be smaller than 0") + + if shrinkage < 0 or shrinkage >= 1: + raise ValueError("shrinkage must be between in the interval [0,1)") + self.n_transactions = transactions - self.asa = asa self.aht = aht self.interval = interval + self.asa = asa self.intensity = (self.n_transactions / self.interval) * self.aht self.shrinkage = shrinkage - self.max_occupancy = max_occupancy def waiting_probability(self, positions, scale_positions=False): """ @@ -67,13 +82,27 @@ def achieved_occupancy(self, positions, scale_positions=False): else: productive_positions = positions - return self.intensity/productive_positions + return self.intensity / productive_positions - def required_positions(self, service_level): + def required_positions(self, service_level: float, max_occupancy: float = 1.0): """ :param service_level: Target service level - :return: Number of positions needed to ensure the required service level + :param max_occupancy: Maximum fraction of time that an attending position can be occupied + :return: + * raw_positions: Required positions assuming shrinkage = 0 + * positions: Number of positions needed to ensure the required service level + * service_level: Fraction of transactions that are expect to be assigned to a position, + before the asa time + * occupancy: Expected occupancy of positions + * waiting_probability: The probability of a transaction waits in queue """ + + if service_level < 0 or service_level > 1: + raise ValueError("service_level must be between 0 and 1") + + if max_occupancy < 0 or service_level > 1: + raise ValueError("max_occupancy must be between 0 and 1") + positions = round(self.intensity + 1) achieved_service_level = self.service_level(positions, scale_positions=False) while achieved_service_level < service_level: @@ -82,12 +111,13 @@ def required_positions(self, service_level): achieved_occupancy = self.achieved_occupancy(positions, scale_positions=False) - if achieved_occupancy > self.max_occupancy: - raw_positions = ceil(self.intensity / self.max_occupancy) + raw_positions = ceil(positions) + + if achieved_occupancy > max_occupancy: + raw_positions = ceil(self.intensity / max_occupancy) achieved_occupancy = self.achieved_occupancy(raw_positions) achieved_service_level = self.service_level(raw_positions) - raw_positions = ceil(positions) waiting_probability = self.waiting_probability(positions=raw_positions) positions = ceil(raw_positions / (1 - self.shrinkage)) diff --git a/pyworkforce/shifts/__init__.py b/pyworkforce/shifts/__init__.py new file mode 100644 index 0000000..48cd4c3 --- /dev/null +++ b/pyworkforce/shifts/__init__.py @@ -0,0 +1,3 @@ +from pyworkforce.shifts.shifts_selection import MinAbsDifference + +__all__ = ["MinAbsDifference"] diff --git a/pyworkforce/shifts/shifts_selection.py b/pyworkforce/shifts/shifts_selection.py new file mode 100644 index 0000000..5d31cb5 --- /dev/null +++ b/pyworkforce/shifts/shifts_selection.py @@ -0,0 +1,126 @@ +import numpy as np +from ortools.sat.python import cp_model +from pyworkforce.shifts.utils import check_positive_integer, check_positive_float + + +class MinAbsDifference: + 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 = 120.0, + 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 # 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 + """ + + 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.transposed_shifts_coverage = None + self.status = None + self.solver = None + + 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') + # transition resources: Variable to change domain coordinates from min |x-a| + # to min t, s.t t>= x-a and t>= a-x + transition_resources = np.empty(shape=(self.num_days, self.num_periods), 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'agents_d{d}s{s}') + + for d in range(self.num_days): + for p in range(self.num_periods): + transition_resources[d][p] = sch_model.NewIntVar(-self.max_period_concurrency, + self.max_period_concurrency, + f'transition_resources_d{d}p{p}') + + # Constrains + + # transition must be between x-a and a-x + for d in range(self.num_days): + for p in range(self.num_periods): + sch_model.Add(transition_resources[d][p] >= ( + sum(resources[d][s] * self.shifts_coverage_matrix[s][p] for s in range(self.num_shifts)) - + self.required_resources[d][p])) + sch_model.Add(transition_resources[d][p] >= (self.required_resources[d][p] + - sum(resources[d][s] * self.shifts_coverage_matrix[s][p] + for s in range(self.num_shifts)))) + + # 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 absolute value of the difference between required and shifted resources + + sch_model.Minimize( + sum(transition_resources[d][p] for d in range(self.num_days) for p in range(self.num_periods))) + + self.solver = cp_model.CpSolver() + 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/utils.py b/pyworkforce/shifts/utils.py new file mode 100644 index 0000000..8827924 --- /dev/null +++ b/pyworkforce/shifts/utils.py @@ -0,0 +1,14 @@ + + +def check_positive_integer(name, value): + if value <= 0 or not isinstance(value, int): + raise ValueError(f"{name} must be a positive integer") + else: + return True + + +def check_positive_float(name, value): + if value <= 0 or not isinstance(value, float): + raise ValueError(f"{name} must be a positive float") + else: + return True diff --git a/setup.py b/setup.py index 8668258..f81bb5c 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ # This call to setup() does all the work setup( name="pyworkforce", - version="0.1.0", + version="0.2.0", description="Common tools for workforce management, schedule and optimization problems", long_description=README, long_description_content_type="text/markdown", @@ -23,8 +23,15 @@ classifiers=[ 'License :: OSI Approved :: MIT License', "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + packages=find_packages(include=['pyworkforce', 'pyworkforce.*']), + install_requires=[ + 'numpy>=1.18.1', + 'ortools>=7.8.7959' ], - packages=find_packages(), include_package_data=True, ) diff --git a/tests/test_queing.py b/tests/test_queing.py index e8421c0..a26bdfe 100644 --- a/tests/test_queing.py +++ b/tests/test_queing.py @@ -1,16 +1,25 @@ -from pyworkforce.queuing.erlang import ErlangC +import pytest +from pyworkforce.queuing import ErlangC def test_expected_erlangc_results(): erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3) - results = erlang.required_positions(service_level=0.8) + results = erlang.required_positions(service_level=0.8, max_occupancy=0.85) raw_positions = results['raw_positions'] positions = results['positions'] service_level = results['service_level'] occupancy = results['occupancy'] + waiting_probability = results['waiting_probability'] assert raw_positions == 14 assert positions == 20 assert round(service_level, 3) == 0.888 assert round(occupancy, 3) == 0.714 + assert round(waiting_probability, 3) == 0.174 + +def test_wrong_service_level_erlangc(): + erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3) + with pytest.raises(Exception) as excinfo: + results = erlang.required_positions(service_level=1.8, max_occupancy=0.85) + assert str(excinfo.value) == "service_level must be between 0 and 1" diff --git a/tests/test_shifts.py b/tests/test_shifts.py new file mode 100644 index 0000000..d2cdd7a --- /dev/null +++ b/tests/test_shifts.py @@ -0,0 +1,30 @@ +from pyworkforce.shifts import MinAbsDifference + + +def test_min_abs_difference_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 = MinAbsDifference(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'] == '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