From e976b7f3c084ba138920de6c6449266343a87d9e Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Mon, 8 Mar 2021 19:51:45 -0500 Subject: [PATCH 01/15] Refactor ErlangC parameters to proper functions --- examples/queing/erlangc.py | 4 ++-- pyworkforce/queuing/erlang.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/queing/erlangc.py b/examples/queing/erlangc.py index f171b6e..43faef2 100644 --- a/examples/queing/erlangc.py +++ b/examples/queing/erlangc.py @@ -15,9 +15,9 @@ from pyworkforce.queuing.erlang 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']) diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 02981e0..21d81f9 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -2,8 +2,8 @@ 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 @@ -11,17 +11,16 @@ def __init__(self, transactions: float, asa: float, aht: float, :param transactions: number of total transactions that comes in :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 """ 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,11 +66,12 @@ 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 + :param max_occupancy: Maximum fraction of time that an attending position can be occupied :return: Number of positions needed to ensure the required service level """ positions = round(self.intensity + 1) @@ -82,8 +82,8 @@ 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) + 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) From fb1d6c7be5dae50047166e344da7afedf793d9fa Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Mon, 8 Mar 2021 20:14:45 -0500 Subject: [PATCH 02/15] Updated README.md based on new ErlangC parameters distribution --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8aab049..fc09079 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ under some systems pre-defined parameters and goals. ```python from pyworkforce.queuing.erlang 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) From 8debd3dc5f8451327b5380b329f9ba23bced749f Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Mon, 8 Mar 2021 21:00:49 -0500 Subject: [PATCH 03/15] ErlangC inputs validation and test --- pyworkforce/queuing/erlang.py | 23 +++++++++++++++++++++++ tests/test_queing.py | 11 ++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 21d81f9..bec79fc 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -15,6 +15,22 @@ def __init__(self, transactions: float, aht: float, asa: float, :param interval: Interval length (minutes) :param shrinkage: Percentage of time that an operator unit is not available """ + + 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 0 and 1") + self.n_transactions = transactions self.aht = aht self.interval = interval @@ -74,6 +90,13 @@ def required_positions(self, service_level: float, max_occupancy: float = 1.0): :param max_occupancy: Maximum fraction of time that an attending position can be occupied :return: Number of positions needed to ensure the required service level """ + + 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: diff --git a/tests/test_queing.py b/tests/test_queing.py index e8421c0..02cf618 100644 --- a/tests/test_queing.py +++ b/tests/test_queing.py @@ -1,16 +1,25 @@ +import pytest from pyworkforce.queuing.erlang 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" From ed8259059d0108b8ba93ee4b6f2a6a7bf77e6b08 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 08:49:42 -0500 Subject: [PATCH 04/15] Fixes raw positions not being updated if achievied occupancy were greater than max occupancy --- pyworkforce/queuing/erlang.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index bec79fc..9f44925 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -105,12 +105,13 @@ def required_positions(self, service_level: float, max_occupancy: float = 1.0): achieved_occupancy = self.achieved_occupancy(positions, scale_positions=False) + 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)) From e3bc8b0456b425e32034034123d429380fb1d0f1 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 08:57:16 -0500 Subject: [PATCH 05/15] More descriptive params documentation --- pyworkforce/queuing/erlang.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 9f44925..1d22da7 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -9,7 +9,7 @@ def __init__(self, transactions: float, aht: float, asa: float, 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) @@ -88,7 +88,13 @@ def required_positions(self, service_level: float, max_occupancy: float = 1.0): """ :param service_level: Target service level :param max_occupancy: Maximum fraction of time that an attending position can be occupied - :return: Number of positions needed to ensure the required service level + :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: From 1c5a40a012447eafb60e167aad8f9b207fb0c2f5 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 08:58:19 -0500 Subject: [PATCH 06/15] Fixes right shrinkage domain range --- pyworkforce/queuing/erlang.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 1d22da7..24833de 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -28,8 +28,8 @@ def __init__(self, transactions: float, aht: float, asa: float, if interval < 0: raise ValueError("interval can't be smaller than 0") - if shrinkage < 0 or shrinkage > 1: - raise ValueError("shrinkage must be between 0 and 1") + if shrinkage < 0 or shrinkage >= 1: + raise ValueError("shrinkage must be between in the interval [0,1)") self.n_transactions = transactions self.aht = aht From 94dcb72517aa34d254455bd98f6c14fc93b3ebdd Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 09:47:23 -0500 Subject: [PATCH 07/15] Package import customization --- README.md | 2 +- examples/queing/erlangc.py | 2 +- pyworkforce/queuing/__init__.py | 3 +++ tests/test_queing.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fc09079..af6b32c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 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, shrinkage=0.3) diff --git a/examples/queing/erlangc.py b/examples/queing/erlangc.py index 43faef2..1dc6701 100644 --- a/examples/queing/erlangc.py +++ b/examples/queing/erlangc.py @@ -13,7 +13,7 @@ Shrinkage: 30% """ -from pyworkforce.queuing.erlang import ErlangC +from pyworkforce.queuing import ErlangC erlang = ErlangC(transactions=100, aht=3, interval=30, asa=20/60, shrinkage=0.3) 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/tests/test_queing.py b/tests/test_queing.py index 02cf618..a26bdfe 100644 --- a/tests/test_queing.py +++ b/tests/test_queing.py @@ -1,5 +1,5 @@ import pytest -from pyworkforce.queuing.erlang import ErlangC +from pyworkforce.queuing import ErlangC def test_expected_erlangc_results(): From 6fd11a0d2e5d67eb51186fe0e7fa4d7f12698e7e Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 09:49:10 -0500 Subject: [PATCH 08/15] Added use of scale positions parameter in erlang example --- examples/queing/erlangc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/queing/erlangc.py b/examples/queing/erlangc.py index 1dc6701..545c604 100644 --- a/examples/queing/erlangc.py +++ b/examples/queing/erlangc.py @@ -23,6 +23,10 @@ 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) From 7f33789f471243271e1cc852a60f90e3d065dbc3 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 17:52:01 -0500 Subject: [PATCH 09/15] Expected supported python versions --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8668258..b6f9dd3 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,7 +23,10 @@ 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_package_data=True, From f286826219823e1fa103ccf801c42796572df876 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 19:02:25 -0500 Subject: [PATCH 10/15] Optimization solver for finding the required number of resources per shift under capacity constrains and requirements per period of day --- pyworkforce/shifts/__init__.py | 3 + pyworkforce/shifts/shifts_selection.py | 118 +++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 pyworkforce/shifts/__init__.py create mode 100644 pyworkforce/shifts/shifts_selection.py 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..7a35015 --- /dev/null +++ b/pyworkforce/shifts/shifts_selection.py @@ -0,0 +1,118 @@ +import numpy as np +from ortools.sat.python import cp_model + + +class MinAbsDifference: + def __init__(self, num_days: int, + periods: int, + shifts_coverage: dict, + required_resources: list, + max_period_concurrency: int = None, + max_shift_concurrency: int = None, + max_search_time: float = 120, + 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 + """ + + 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 + if self.max_shift_concurrency is not None: + 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 From 3c00a7db33984682282da2ab63ca929230829b88 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 19:03:13 -0500 Subject: [PATCH 11/15] Examples of the use of MinAbsDifference solver --- README.md | 51 ++++++++++++++++++++++++++- examples/shifts/min_abs_difference.py | 35 ++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 examples/shifts/min_abs_difference.py diff --git a/README.md b/README.md index af6b32c..4948243 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: @@ -25,3 +29,48 @@ 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 + + +```python +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 :", 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/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) From 4096cec018947a39f31e100be8eb6bda89ce0cc7 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 19:35:33 -0500 Subject: [PATCH 12/15] Basic test for MinAbsDifference shifts solver --- tests/test_shifts.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_shifts.py 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 From a5b0e69ba3f14bac43a8b94a6dcae5778ee58b81 Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 19:35:52 -0500 Subject: [PATCH 13/15] Check some basic expected parameters range --- pyworkforce/shifts/shifts_selection.py | 26 +++++++++++++++++--------- pyworkforce/shifts/utils.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 pyworkforce/shifts/utils.py diff --git a/pyworkforce/shifts/shifts_selection.py b/pyworkforce/shifts/shifts_selection.py index 7a35015..5d31cb5 100644 --- a/pyworkforce/shifts/shifts_selection.py +++ b/pyworkforce/shifts/shifts_selection.py @@ -1,5 +1,6 @@ import numpy as np from ortools.sat.python import cp_model +from pyworkforce.shifts.utils import check_positive_integer, check_positive_float class MinAbsDifference: @@ -7,9 +8,9 @@ def __init__(self, num_days: int, periods: int, shifts_coverage: dict, required_resources: list, - max_period_concurrency: int = None, - max_shift_concurrency: int = None, - max_search_time: float = 120, + max_period_concurrency: int, + max_shift_concurrency: int, + max_search_time: float = 120.0, num_search_workers=4, *args, **kwargs): """ @@ -34,6 +35,13 @@ def __init__(self, num_days: int, :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) @@ -58,10 +66,9 @@ def solve(self): transition_resources = np.empty(shape=(self.num_days, self.num_periods), dtype='object') # Resources - if self.max_shift_concurrency is not None: - 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 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): @@ -84,8 +91,9 @@ def solve(self): # 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) + 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 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 From a43383f0fbdcdfd7919d5f337401631474953f9f Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 19:50:52 -0500 Subject: [PATCH 14/15] Downgraded the required version of numpy --- setup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b6f9dd3..f81bb5c 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,10 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", ], - packages=find_packages(), + packages=find_packages(include=['pyworkforce', 'pyworkforce.*']), + install_requires=[ + 'numpy>=1.18.1', + 'ortools>=7.8.7959' + ], include_package_data=True, ) From e006e5f85f4cf6cd6f918fd4abbd587020b954df Mon Sep 17 00:00:00 2001 From: rodrigoarenas456 <31422766+rodrigoarenas456@users.noreply.github.com> Date: Tue, 9 Mar 2021 19:59:56 -0500 Subject: [PATCH 15/15] Improved example description --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4948243..7c50ae1 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,18 @@ print("positions_requirements: ", positions_requirements) 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 -# Columns are an hour of the day, rows are the days +# 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 columns) +# 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],