From 90a010b985ba565b6ad57ef67c82b024650847de Mon Sep 17 00:00:00 2001 From: "rodrigo.arenas" <31422766+rodrigo-arenas@users.noreply.github.com> Date: Wed, 18 May 2022 19:04:37 -0500 Subject: [PATCH 1/5] Initial docs with queuing classes --- docs/Makefile | 20 ++++ docs/api/erlangc.rst | 15 +++ docs/api/multierlangc.rst | 15 +++ docs/conf.py | 77 ++++++++++++ docs/index.rst | 37 ++++++ docs/make.bat | 35 ++++++ pyworkforce/__init__.py | 8 ++ pyworkforce/_version.py | 1 + pyworkforce/queuing/erlang.py | 220 ++++++++++++++++++++++++---------- setup.py | 8 +- 10 files changed, 372 insertions(+), 64 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/api/erlangc.rst create mode 100644 docs/api/multierlangc.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 pyworkforce/_version.py diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..ed88099 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api/erlangc.rst b/docs/api/erlangc.rst new file mode 100644 index 0000000..ece6c63 --- /dev/null +++ b/docs/api/erlangc.rst @@ -0,0 +1,15 @@ +ErlangC +------- + +.. currentmodule:: pyworkforce.queuing + +.. autosummary:: ErlangC + ErlangC.waiting_probability + ErlangC.service_level + ErlangC.achieved_occupancy + ErlangC.required_positions + +.. autoclass:: pyworkforce.queuing.ErlangC + :members: + :inherited-members: + :undoc-members: True \ No newline at end of file diff --git a/docs/api/multierlangc.rst b/docs/api/multierlangc.rst new file mode 100644 index 0000000..f05d22a --- /dev/null +++ b/docs/api/multierlangc.rst @@ -0,0 +1,15 @@ +MultiErlangC +------------ + +.. currentmodule:: pyworkforce.queuing + +.. autosummary:: MultiErlangC + MultiErlangC.waiting_probability + MultiErlangC.service_level + MultiErlangC.achieved_occupancy + MultiErlangC.required_positions + +.. autoclass:: pyworkforce.queuing.MultiErlangC + :members: + :inherited-members: + :undoc-members: True \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..0f32fe4 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,77 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +from datetime import datetime + +sys.path.insert(0, os.path.abspath("..")) + +from pyworkforce import __version__ + +# -- Project information ----------------------------------------------------- + +project = 'pyworkforce' +copyright = f"2021--{datetime.now().year}, Rodrigo Arenas Gómez" +author = 'Rodrigo Arenas Gómez' + +release = __version__ +version = __version__ + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "numpydoc", + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", + "sphinx_copybutton", + "sphinx_rtd_theme", + "nbsphinx", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"] + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +master_doc = "index" + +# generate autosummary even if no references +autosummary_generate = True +autosummary_imported_members = True + +autoclass_content = "both" + +numpydoc_show_class_members = False +numpydoc_class_members_toctree = False + +todo_include_todos = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0c5c5af --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,37 @@ +.. pyworkforce documentation master file, created by + sphinx-quickstart on Fri May 13 15:38:45 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +pyworkforce +=========== +Common tools for workforce management, +schedule and optimization problems built on top of packages like google's +or-tools and custom modules. + +######################################################################### +This package implements a python interface for common problems in operations research +applied to queue and scheduling problems, among others. + +Installation: +############# + +Install pyworkforce + +It's advised to install pyworkforce using a virtual env, inside the env use:: + + pip install pyworkforce + +.. toctree:: + :maxdepth: 2 + :caption: API Reference: + + api/erlangc + api/multierlangc + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..922152e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/pyworkforce/__init__.py b/pyworkforce/__init__.py index e69de29..59e6679 100644 --- a/pyworkforce/__init__.py +++ b/pyworkforce/__init__.py @@ -0,0 +1,8 @@ +from .queuing import ErlangC, MultiErlangC +from ._version import __version__ + +__all__ = [ + "ErlangC", + "MultiErlangC", + "__version__", +] diff --git a/pyworkforce/_version.py b/pyworkforce/_version.py new file mode 100644 index 0000000..8f1231e --- /dev/null +++ b/pyworkforce/_version.py @@ -0,0 +1 @@ +__version__ = "0.4.1.dev0" diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 462a2c2..49a1c18 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -4,19 +4,28 @@ class ErlangC: + """ + Computes the number of positions required to attend a number of transactions in a + queue system based on ErlangC. Implementation inspired on: + https://lucidmanager.org/data-science/call-centre-workforce-planning-erlang-c-in-r/ + + Parameters + ---------- + transactions: float, + The number of total transactions that comes in an interval. + aht: float, + Average handling time of a transaction (minutes). + asa: float, + The required average speed of answer (minutes). + interval: int, + Interval length (minutes) where the transactions come in + shrinkage: float, + Percentage of time that an operator unit is not available. + """ + def __init__(self, transactions: float, aht: float, asa: float, - interval: int = None, shrinkage=0.0, + interval: int, 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 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 - """ if transactions <= 0: raise ValueError("transactions can't be smaller or equals than 0") @@ -40,12 +49,19 @@ def __init__(self, transactions: float, aht: float, asa: float, self.intensity = (self.n_transactions / self.interval) * self.aht self.shrinkage = shrinkage - def waiting_probability(self, positions, scale_positions=False): + def waiting_probability(self, positions: int, scale_positions: bool = False): """ - :param positions: Number of positions to attend the transactions - :param scale_positions: True if the positions where calculated using shrinkage - :return: the probability of a transaction waits in queue + Returns the probability of waiting in the queue + + Parameters + ---------- + positions: int, + The number of positions to attend the transactions. + scale_positions: bool, default=False + Set it to True if the positions were calculated using shrinkage. + """ + if scale_positions: productive_positions = floor((1 - self.shrinkage) * positions) else: @@ -58,11 +74,18 @@ def waiting_probability(self, positions, scale_positions=False): erlang_b = 1 / erlang_b_inverse return productive_positions * erlang_b / (productive_positions - self.intensity * (1 - erlang_b)) - def service_level(self, positions, scale_positions=False): + def service_level(self, positions: int, scale_positions: bool = False): """ - :param positions: Number of positions attending - :param scale_positions: True if the positions where calculated using shrinkage - :return: achieved service level + Returns the expected service level given a number of positions + + Parameters + ---------- + + positions: int, + The number of positions attending. + scale_positions: bool, default = False + Set it to True if the positions were calculated using shrinkage. + """ if scale_positions: productive_positions = floor((1 - self.shrinkage) * positions) @@ -73,11 +96,18 @@ def service_level(self, positions, scale_positions=False): exponential = exp(-(productive_positions - self.intensity) * (self.asa / self.aht)) return max(0, 1 - (probability_wait * exponential)) - def achieved_occupancy(self, positions, scale_positions=False): + def achieved_occupancy(self, positions: int, scale_positions: bool = False): """ - :param positions: Number of raw positions - :param scale_positions: True if the positions where calculated using shrinkage - :return: Expected occupancy of positions + Returns the expected occupancy of positions + + Parameters + ---------- + + positions: int, + The number of raw positions + scale_positions: bool, default=False + Set it to True if the positions were calculated using shrinkage. + """ if scale_positions: productive_positions = floor((1 - self.shrinkage) * positions) @@ -88,15 +118,30 @@ def achieved_occupancy(self, positions, scale_positions=False): 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: - * 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 + Computes the requirements using ErlangC + + Parameters + ---------- + + service_level: float, + Target service level + max_occupancy: float, + The maximum fraction of time that a transaction can occupy a position + + Returns + ------- + + raw_positions: int, + The required positions assuming shrinkage = 0 + positions: int, + The number of positions needed to ensure the required service level + service_level: float, + The fraction of transactions that are expected to be assigned to a position, + before the asa time + occupancy: float, + The expected occupancy of positions + waiting_probability: float, + The probability of a transaction waiting in the queue """ if service_level < 0 or service_level > 1: @@ -132,20 +177,31 @@ def required_positions(self, service_level: float, max_occupancy: float = 1.0): class MultiErlangC: """ - This class uses de ErlangC class using joblib's Parallel allowing to run multiples scenarios with one class. - It finds solutions iterating over all possible combinations provided by the users, inspired how Sklearn's Grid - Search works - """ + This class uses the ErlangC class using joblib's Parallel, + allowing to run multiple scenarios at once. + It finds solutions iterating over all possible combinations provided by the users, + inspired how Sklearn's Grid Search works - def __init__(self, param_grid, n_jobs=2, pre_dispatch='2 * n_jobs'): - """ - :param param_grid: Dictionary with the ErlangC.__init__ parameters, each key of the dictionary must be the + Parameters + ---------- + + param_grid: dict, + Dictionary with the ErlangC.__init__ parameters, each key of the dictionary must be the expected parameter and the value must be a list with the different options to iterate example: {"transactions": [100, 200], "aht": [3], "interval": [30], "asa": [20 / 60], "shrinkage": [0.3]} - :param n_jobs: The maximum number of concurrently running jobs - :param pre_dispatch:The number of batches (of tasks) to be pre-dispatched. Default is ‘2*n_jobs’. + n_jobs: int, default=2 + The maximum number of concurrently running jobs. + If -1 all CPUs are used. If 1 is given, no parallel computing code is used at all, which is useful for debugging. + For n_jobs below -1, (n_cpus + 1 + n_jobs) are used. Thus for n_jobs = -2, all CPUs but one are used. + None is a marker for ‘unset’ that will be interpreted as n_jobs=1 (sequential execution) + unless the call is performed under a parallel_backend() context manager that sets another value for n_jobs. + pre_dispatch: {"all", int, or expression}, default='2 * n_jobs' + The number of batches (of tasks) to be pre-dispatched. Default is ‘2*n_jobs’. See joblib's documentation for more details: https://joblib.readthedocs.io/en/latest/generated/joblib.Parallel.html - """ + """ + + def __init__(self, param_grid: dict, n_jobs: int = 2, pre_dispatch: str = '2 * n_jobs'): + self.param_grid = param_grid self.n_jobs = n_jobs self.pre_dispatch = pre_dispatch @@ -153,27 +209,45 @@ def __init__(self, param_grid, n_jobs=2, pre_dispatch='2 * n_jobs'): def waiting_probability(self, arguments_grid): """ - :param arguments_grid: Dictionary with the ErlangC.achieved_occupancy parameters, each key of the dictionary must be the - expected parameter and the value must be a list with the different options to iterate - example: {"positions": [10, 20, 30], "scale_positions": [True, False]} - :return: A list with the solution to all the possible combinations from the arguments_grid and the ErlangC param_grid + Returns the probability of waiting in the queue + Returns a list with the solution to all the possible combinations from the arguments_grid + and the ErlangC param_grid + + Parameters + ---------- + + arguments_grid: dict, + Dictionary with the ErlangC.waiting_probability parameters, + each key of the dictionary must be the expected parameter and + the value must be a list with the different options to iterate + example: {"positions": [10, 20, 30], "scale_positions": [True, False]} """ + arguments_list = list(ParameterGrid(arguments_grid)) combinations = len(self.param_list) * len(arguments_list) results = Parallel(n_jobs=self.n_jobs, pre_dispatch=self.pre_dispatch)(delayed(ErlangC(**params).waiting_probability)(**arguments) for params in self.param_list for arguments in arguments_list) - self.check_solutions(results, combinations) + self._check_solutions(results, combinations) return results def service_level(self, arguments_grid): """ - :param arguments_grid: Dictionary with the ErlangC.required_positions parameters, each key of the dictionary must be the - expected parameter and the value must be a list with the different options to iterate - example: {"positions": [10, 20, 30], "scale_positions": [True, False]} - :return: A list with the solution to all the possible combinations from the arguments_grid and the ErlangC param_grid + Returns the expected service level given a number of positions + Returns a list with the solution to all the possible combinations from the arguments_grid + and the ErlangC param_grid + + Parameters + ---------- + + arguments_grid: dict, + Dictionary with the ErlangC.service_level parameters, + each key of the dictionary must be the expected parameter and + the value must be a list with the different options to iterate + example: {"positions": [10, 20, 30], "scale_positions": [True, False]} + """ arguments_list = list(ParameterGrid(arguments_grid)) combinations = len(self.param_list) * len(arguments_list) @@ -181,45 +255,65 @@ def service_level(self, arguments_grid): pre_dispatch=self.pre_dispatch)(delayed(ErlangC(**params).service_level)(**arguments) for params in self.param_list for arguments in arguments_list) - self.check_solutions(results, combinations) + self._check_solutions(results, combinations) return results def achieved_occupancy(self, arguments_grid): """ - :param arguments_grid: Dictionary with the ErlangC.achieved_occupancy parameters, each key of the dictionary must be the - expected parameter and the value must be a list with the different options to iterate - example: {"positions": [10, 20, 30], "scale_positions": [True, False]} - :return: A list with the solution to all the possible combinations from the arguments_grid and the ErlangC param_grid + Returns the expected occupancy of positions + Returns a list with the solution to all the possible combinations from the arguments_grid + and the ErlangC param_grid + + Parameters + ---------- + + arguments_grid: dict, + Dictionary with the ErlangC.achieved_occupancy parameters, + each key of the dictionary must be the expected parameter and + the value must be a list with the different options to iterate + example: {"positions": [10, 20, 30], "scale_positions": [True, False]} """ + arguments_list = list(ParameterGrid(arguments_grid)) combinations = len(self.param_list) * len(arguments_list) results = Parallel(n_jobs=self.n_jobs, pre_dispatch=self.pre_dispatch)(delayed(ErlangC(**params).achieved_occupancy)(**arguments) for params in self.param_list for arguments in arguments_list) - self.check_solutions(results, combinations) + self._check_solutions(results, combinations) return results def required_positions(self, arguments_grid): """ - :param arguments_grid: Dictionary with the ErlangC.required_positions parameters, each key of the dictionary must be the - expected parameter and the value must be a list with the different options to iterate - example: {"service_level": [0.85, 0.9], "max_occupancy": [0.8, 0.95]} - :return: A list with the solution to all the possible combinations from the arguments_grid and the ErlangC param_grid + Computes the requirements using MultiErlangC + Returns a list with the solution to all the possible combinations from the arguments_grid and the ErlangC param_grid + + Parameters + ---------- + + arguments_grid: dict, + Dictionary with the ErlangC.achieved_occupancy parameters, + each key of the dictionary must be the expected parameter and + the value must be a list with the different options to iterate + example: {"service_level": [0.85, 0.9], "max_occupancy": [0.8, 0.95]} """ + arguments_list = list(ParameterGrid(arguments_grid)) combinations = len(self.param_list) * len(arguments_list) results = Parallel(n_jobs=self.n_jobs, pre_dispatch=self.pre_dispatch)(delayed(ErlangC(**params).required_positions)(**arguments) for params in self.param_list for arguments in arguments_list) - self.check_solutions(results, combinations) + self._check_solutions(results, combinations) return results - def check_solutions(self, solutions, combinations): + def _check_solutions(self, solutions, combinations): + """ + Checks the integrity of the solution in terms of dimensions + """ if len(solutions) < 1: raise ValueError("Could not find any solution, make sure the param_grid is defined correctly") diff --git a/setup.py b/setup.py index a90003d..eedcafd 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,22 @@ +import os import pathlib from setuptools import setup, find_packages # python setup.py sdist bdist_wheel # twine upload --skip-existing --repository-url https://test.pypi.org/legacy/ dist/* +# get __version__ from _version.py +ver_file = os.path.join("pyworkforce", "_version.py") +with open(ver_file) as f: + exec(f.read()) + HERE = pathlib.Path(__file__).parent README = (HERE / "README.md").read_text() setup( name="pyworkforce", - version="0.4.0", + version=__version__, description="Common tools for workforce management, schedule and optimization problems", long_description=README, long_description_content_type="text/markdown", From b6db62805272497e92e08ccfd1e3a87218e5282b Mon Sep 17 00:00:00 2001 From: "rodrigo.arenas" <31422766+rodrigo-arenas@users.noreply.github.com> Date: Fri, 27 May 2022 14:51:05 -0500 Subject: [PATCH 2/5] tests with GitHub workflows --- .coveragerc | 6 ++++- .github/workflows/ci-tests.yml | 42 ++++++++++++++++++++++++++++++++++ setup.py | 3 +-- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci-tests.yml diff --git a/.coveragerc b/.coveragerc index e5d022e..3fc5db6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,4 +8,8 @@ omit = setup.py [report] precision = 2 -show_missing = True \ No newline at end of file +show_missing = True +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + noqa \ No newline at end of file diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000..6fcf90e --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,42 @@ +name: Tests + +on: [push, pull_request] + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [ 3.7, 3.8, 3.9] + os: [ubuntu-latest, windows-latest, macOS-latest] + include: + - os: ubuntu-latest + path: ~/.cache/pip + - os: macos-latest + path: ~/Library/Caches/pip + - os: windows-latest + path: ~\AppData\Local\pip\Cache + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + with: + path: ${{ matrix.path }} + key: ${{ runner.os }}-pip-${{ hashFiles('dev-requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install --upgrade --upgrade-strategy eager -r dev-requirements.txt -e . + - name: Test with pytest + run: | + pytest pyworkforce/ --verbose --color=yes --assert=plain --cov-fail-under=95 --cov-config=.coveragerc --cov=./ -p no:warnings + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v1 + with: + fail_ci_if_error: true \ No newline at end of file diff --git a/setup.py b/setup.py index eedcafd..b15430f 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,6 @@ 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", @@ -39,6 +38,6 @@ 'pandas', 'joblib>=0.11' ], - python_requires=">=3.6", + python_requires=">=3.7", include_package_data=True, ) From d8fab7ac897f310c9f0870ff6435a12078764bb3 Mon Sep 17 00:00:00 2001 From: "rodrigo.arenas" <31422766+rodrigo-arenas@users.noreply.github.com> Date: Mon, 30 May 2022 13:01:29 -0500 Subject: [PATCH 3/5] API docs and ErlangC tutorial --- docs/api/min_abs_difference.rst | 12 ++++ docs/api/min_required_resources.rst | 12 ++++ docs/images/erlangc_queue_system.png | Bin 0 -> 34537 bytes docs/index.rst | 10 ++++ docs/tutorials/erlangc.rst | 54 +++++++++++++++++ docs/tutorials/erlangc_example.rst | 48 +++++++++++++++ pyworkforce/queuing/erlang.py | 24 ++++---- pyworkforce/shifts/base.py | 39 ++++++++----- pyworkforce/shifts/shifts_selection.py | 78 ++++++++++++++++++++++--- 9 files changed, 242 insertions(+), 35 deletions(-) create mode 100644 docs/api/min_abs_difference.rst create mode 100644 docs/api/min_required_resources.rst create mode 100644 docs/images/erlangc_queue_system.png create mode 100644 docs/tutorials/erlangc.rst create mode 100644 docs/tutorials/erlangc_example.rst diff --git a/docs/api/min_abs_difference.rst b/docs/api/min_abs_difference.rst new file mode 100644 index 0000000..738ad98 --- /dev/null +++ b/docs/api/min_abs_difference.rst @@ -0,0 +1,12 @@ +MinAbsDifference +---------------- + +.. currentmodule:: pyworkforce.shifts + +.. autosummary:: MinAbsDifference + MinAbsDifference.solve + +.. autoclass:: pyworkforce.shifts.MinAbsDifference + :members: + :inherited-members: + :undoc-members: True \ No newline at end of file diff --git a/docs/api/min_required_resources.rst b/docs/api/min_required_resources.rst new file mode 100644 index 0000000..06ec3ee --- /dev/null +++ b/docs/api/min_required_resources.rst @@ -0,0 +1,12 @@ +MinRequiredResources +-------------------- + +.. currentmodule:: pyworkforce.shifts + +.. autosummary:: MinRequiredResources + MinRequiredResources.solve + +.. autoclass:: pyworkforce.shifts.MinRequiredResources + :members: + :inherited-members: + :undoc-members: True \ No newline at end of file diff --git a/docs/images/erlangc_queue_system.png b/docs/images/erlangc_queue_system.png new file mode 100644 index 0000000000000000000000000000000000000000..21af4d85150249b2cde5b60cdd736c71d32221b4 GIT binary patch literal 34537 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 j$*;^?FYtq8b$|a&>>bC|C>yMjA literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 0c5c5af..79db881 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,12 +22,22 @@ It's advised to install pyworkforce using a virtual env, inside the env use:: pip install pyworkforce +.. toctree:: + :maxdepth: 2 + :titlesonly: + :caption: User Guide / Tutorials: + + tutorials/erlangc + tutorials/erlangc_example + .. toctree:: :maxdepth: 2 :caption: API Reference: api/erlangc api/multierlangc + api/min_abs_difference + api/min_required_resources Indices and tables ================== diff --git a/docs/tutorials/erlangc.rst b/docs/tutorials/erlangc.rst new file mode 100644 index 0000000..272f5c6 --- /dev/null +++ b/docs/tutorials/erlangc.rst @@ -0,0 +1,54 @@ +.. _erlangc: + +Understanding ErlangC for Queue Problems +======================================== + +Introduction +------------ + +Finding the number of positions to use in a queue system has been a study case for a long time now; +it has applications in several fields and industries, for example, finding the optimal number of call centers agents, +deciding the number of bankers in a support station, network traffic analysis and so on. + +There are several methods to analyze this problem; +this article will look at how to model it using the ErlangC approach. + +Queue System +------------ + +In the most fundamental Erlang C method, we represent the system as a queue with the following assumptions: + +* There is incoming traffic with a constant rate; the arrivals follow a Poisson process +* There is a fixed capacity in the system; usually, only one transaction gets handled by a resource at the time +* There is a fixed number of available positions in a time interval +* When all the positions have a total capacity, there is an infinite queue length + where the requests wait for a position to be free. +* An exponential distribution describes the holding times in the queue +* There is no dropout from the queue. + +A queue system with these characteristics may look like this: + +.. image:: ../images/erlangc_queue_system.png + +In this representation, we can see several measures that will help us to describe the system; +here there are their definitions and how we are going to call them from now on: + +* **Transactions:** Number of incoming requests +* **Resource:** The element that handles a transaction +* **Arrival rate:** The number of incoming transactions in a time interval +* **Average speed of answer (ASA):** Average time that a transaction waits in the queue to be attended by a resource +* **Average handle time (AHT):** Average time that takes to a single resource to attend a transaction + +Other variables are in the diagram, but that is important for the model, those are: + +* **Shrinkage:** Expected percentage of time that a server is not available, for example, + due to breaks, scheduled training, etc. +* **Occupancy:** Percentage of time that a resource is handling a transaction +* **Service level:** Percentage of transactions that arrives at a resource before a target ASA + +The way as Erlang C find the number of resources in this system is by finding the probability +that a transaction waits in queue, opposed to immediately being attended, it takes a target ASA +and service level and uses the others variables as the system parameters, if you want to know more about the details of +Erlang formulation, you can red the definition `here `_ + +In the following article, we'll explain how to use pyworkforce to solve this kind of problem. \ No newline at end of file diff --git a/docs/tutorials/erlangc_example.rst b/docs/tutorials/erlangc_example.rst new file mode 100644 index 0000000..06639ca --- /dev/null +++ b/docs/tutorials/erlangc_example.rst @@ -0,0 +1,48 @@ +.. _erlangc_example: + +Solving Queue Problems with ErlangC +=================================== + +As an example, we will find the number of agents needed in a call center to handle incoming traffic of calls. +Make sure to read the previous article to understand the concepts behind this formulation. + +Under the given convention, the resources would be the agent's stations, +and the transactions would be the calls under this scenario. + +Let's assume that in a time interval of 30 minutes, there is an average of 100 incoming calls, +the AHT is 3 minutes, and the expected shrinkage is 30%. + +As the call center administrators, we want the average time that a transaction waits in the queue to be 20 seconds +and achieve a service level of 80%. +We also want to ensure that the maximum occupancy of the agents is not greater than 85%. + +The use of this package is very straightforward; we import ErlangC and initialize the class with the given parameters, +then we use the method `required_positions` to find the minimum number of resources to handle the transactions. +Take into account that the class expects all the time variables to be in minutes: + +.. code:: python3 + + from pyworkforce.queuing import ErlangC + + erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, shrinkage=0.3) + + requirements = erlang.required_positions(service_level=0.8, max_occupancy=0.85) + print(requirements) + +The output of this code should look like this: + +.. code:: python3 + + {'raw_positions': 14, + 'positions': 20, + 'service_level': 0.888, + 'occupancy': 0.714, + 'waiting_probability': 0.174} + +What this dictionary return is: + +* **raw_positions:** Number of positions found assuming shrinkage = 0 +* **positions:** Number of places found taking the shrinkage provided by the user +* **service_level:** The expected percentage of transactions that don't wait in the queue longer than the target ASA +* **occupancy:** The expected occupancy that the system is going to have +* **waiting_probability:** The probability that a transaction waits in the queue diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 49a1c18..152c601 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -6,7 +6,7 @@ class ErlangC: """ Computes the number of positions required to attend a number of transactions in a - queue system based on ErlangC. Implementation inspired on: + queue system based on erlangc.rst. Implementation inspired on: https://lucidmanager.org/data-science/call-centre-workforce-planning-erlang-c-in-r/ Parameters @@ -118,7 +118,7 @@ def achieved_occupancy(self, positions: int, scale_positions: bool = False): def required_positions(self, service_level: float, max_occupancy: float = 1.0): """ - Computes the requirements using ErlangC + Computes the requirements using erlangc.rst Parameters ---------- @@ -177,7 +177,7 @@ def required_positions(self, service_level: float, max_occupancy: float = 1.0): class MultiErlangC: """ - This class uses the ErlangC class using joblib's Parallel, + This class uses the erlangc.rst class using joblib's Parallel, allowing to run multiple scenarios at once. It finds solutions iterating over all possible combinations provided by the users, inspired how Sklearn's Grid Search works @@ -186,7 +186,7 @@ class MultiErlangC: ---------- param_grid: dict, - Dictionary with the ErlangC.__init__ parameters, each key of the dictionary must be the + Dictionary with the erlangc.rst.__init__ parameters, each key of the dictionary must be the expected parameter and the value must be a list with the different options to iterate example: {"transactions": [100, 200], "aht": [3], "interval": [30], "asa": [20 / 60], "shrinkage": [0.3]} n_jobs: int, default=2 @@ -211,13 +211,13 @@ def waiting_probability(self, arguments_grid): """ Returns the probability of waiting in the queue Returns a list with the solution to all the possible combinations from the arguments_grid - and the ErlangC param_grid + and the erlangc.rst param_grid Parameters ---------- arguments_grid: dict, - Dictionary with the ErlangC.waiting_probability parameters, + Dictionary with the erlangc.rst.waiting_probability parameters, each key of the dictionary must be the expected parameter and the value must be a list with the different options to iterate example: {"positions": [10, 20, 30], "scale_positions": [True, False]} @@ -237,13 +237,13 @@ def service_level(self, arguments_grid): """ Returns the expected service level given a number of positions Returns a list with the solution to all the possible combinations from the arguments_grid - and the ErlangC param_grid + and the erlangc.rst param_grid Parameters ---------- arguments_grid: dict, - Dictionary with the ErlangC.service_level parameters, + Dictionary with the erlangc.rst.service_level parameters, each key of the dictionary must be the expected parameter and the value must be a list with the different options to iterate example: {"positions": [10, 20, 30], "scale_positions": [True, False]} @@ -263,13 +263,13 @@ def achieved_occupancy(self, arguments_grid): """ Returns the expected occupancy of positions Returns a list with the solution to all the possible combinations from the arguments_grid - and the ErlangC param_grid + and the erlangc.rst param_grid Parameters ---------- arguments_grid: dict, - Dictionary with the ErlangC.achieved_occupancy parameters, + Dictionary with the erlangc.rst.achieved_occupancy parameters, each key of the dictionary must be the expected parameter and the value must be a list with the different options to iterate example: {"positions": [10, 20, 30], "scale_positions": [True, False]} @@ -288,13 +288,13 @@ def achieved_occupancy(self, arguments_grid): def required_positions(self, arguments_grid): """ Computes the requirements using MultiErlangC - Returns a list with the solution to all the possible combinations from the arguments_grid and the ErlangC param_grid + Returns a list with the solution to all the possible combinations from the arguments_grid and the erlangc.rst param_grid Parameters ---------- arguments_grid: dict, - Dictionary with the ErlangC.achieved_occupancy parameters, + Dictionary with the erlangc.rst.achieved_occupancy parameters, each key of the dictionary must be the expected parameter and the value must be a list with the different options to iterate example: {"service_level": [0.85, 0.9], "max_occupancy": [0.8, 0.95]} diff --git a/pyworkforce/shifts/base.py b/pyworkforce/shifts/base.py index 40899be..4f63fcf 100644 --- a/pyworkforce/shifts/base.py +++ b/pyworkforce/shifts/base.py @@ -10,24 +10,35 @@ def __init__(self, num_days: int, max_period_concurrency: int, max_shift_concurrency: int, max_search_time: float = 240.0, - num_search_workers=4): + num_search_workers=2): """ - Base class to solve the following schedule problem: + 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) + 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) + + Parameters + ---------- - :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 - """ + num_days: int, + Number of days needed to schedule + periods: int, + Number of working periods in a day + shifts_coverage: dict, + 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 + required_resources: list, + Array of size [days, periods] + max_period_concurrency: int, + Maximum resources that are allowed to shift in any period and day + max_shift_concurrency: int, + Number of maximum allowed resources in the same shift + max_search_time: float, default = 240 + Maximum time in seconds to search for a solution + num_search_workers: int, default = 2 + Number of workers to search for a solution + """ is_valid_num_days = check_positive_integer("num_days", num_days) is_valid_periods = check_positive_integer("periods", periods) diff --git a/pyworkforce/shifts/shifts_selection.py b/pyworkforce/shifts/shifts_selection.py index e688262..432d872 100644 --- a/pyworkforce/shifts/shifts_selection.py +++ b/pyworkforce/shifts/shifts_selection.py @@ -12,11 +12,32 @@ def __init__(self, num_days: int, max_period_concurrency: int, max_shift_concurrency: int, max_search_time: float = 120.0, - num_search_workers=4, + num_search_workers=2, *args, **kwargs): """ - 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 + The "optimal" criteria is defined as the number of resources per shift + that minimize the total absolute difference between the required resources + per period and the actual shifts found by the solver + + Parameters + ---------- + + num_days: int, + Number of days needed to schedule + periods: int, + Number of working periods in a day + shifts_coverage: dict, + 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 + required_resources: list, + Array of size [days, periods] + max_period_concurrency: int, + Maximum resources that are allowed to shift in any period and day + max_shift_concurrency: int, + Number of maximum allowed resources in the same shift + max_search_time: float, default = 240 + Maximum time in seconds to search for a solution + num_search_workers: int, default = 2 + Number of workers to search for a solution """ super().__init__(num_days, @@ -29,6 +50,15 @@ def __init__(self, num_days: int, num_search_workers) def solve(self): + """ + Runs the optimization solver + + Returns + ------- + solution: dict, + Dictionary with the status on the optimization, the resources to schedule per day and the + final value of the cost function + """ sch_model = cp_model.CpModel() # Resources: Number of resources assigned in day d to shift s @@ -106,14 +136,35 @@ def __init__(self, num_days: int, max_shift_concurrency: int, cost_dict: dict = None, max_search_time: float = 240.0, - num_search_workers: int = 4, + num_search_workers: int = 2, *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 + The "optimal" criteria is defined as the minimum weighted amount + of resources (by optional shift cost), that ensures that there are never + fewer resources shifted than the ones required per period + + Parameters + ---------- + + num_days: int, + Number of days needed to schedule + periods: int, + Number of working periods in a day + shifts_coverage: dict, + 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 + required_resources: list, + Array of size [days, periods] + max_period_concurrency: int, + Maximum resources that are allowed to shift in any period and day + max_shift_concurrency: int, + Number of maximum allowed resources in the same shift + cost_dict: dict, default = None + dictionary of form {shift: cost_value}, where shift must be the same options listed in the + shifts_coverage matrix, and they must be all integers + max_search_time: float, default = 240 + Maximum time in seconds to search for a solution + num_search_workers: int, default = 2 + Number of workers to search for a solution """ super().__init__(num_days, @@ -136,6 +187,15 @@ def __init__(self, num_days: int, raise KeyError('cost_dict must have the same keys as shifts_coverage') def solve(self): + """ + Runs the optimization solver + + Returns + ------- + solution: dict, + Dictionary with the status on the optimization, the resources to schedule per day and the + final value of the cost function + """ sch_model = cp_model.CpModel() # Resources: Number of resources assigned in day d to shift s From e8de9c11cdda6a78b849fa5e510f34c80a11580f Mon Sep 17 00:00:00 2001 From: "rodrigo.arenas" <31422766+rodrigo-arenas@users.noreply.github.com> Date: Mon, 30 May 2022 13:32:55 -0500 Subject: [PATCH 4/5] Add pyworkforce docs url --- README.md | 15 +++++++++------ setup.py | 5 +++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0b38468..609a603 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,24 @@ # pyworkforce -Common tools for workforce management, schedule and optimization problems built on top of packages like google's or-tools -and custom modules +Standard tools for workforce management, queue, schedule, and optimization problems built on top of packages like google's or-tools +and custom modules. +Documentatio is available [here](https://pyworkforce.readthedocs.io/) ## Features: pyworkforce currently includes: [Queue Systems](./pyworkforce/queuing): -- **queing.ErlangC:** Find the number of positions required to attend incoming traffic to a constant rate, 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): -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. +It finds the number of resources to schedule in a shift based on 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. + never fewer resources shifted than the ones required per period. # Usage: diff --git a/setup.py b/setup.py index b15430f..8048350 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,11 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", ], + project_urls={ + "Documentation": "https://pyworkforce.readthedocs.io/en/stable/", + "Source Code": "https://github.com/rodrigo-arenas/pyworkforce", + "Bug Tracker": "https://github.com/rodrigo-arenas/pyworkforce/issues", + }, packages=find_packages(include=['pyworkforce', 'pyworkforce.*']), install_requires=[ 'numpy', From 91f4ddf8f28dda43af1e515c78c6eb6ff912228b Mon Sep 17 00:00:00 2001 From: "rodrigo.arenas" <31422766+rodrigo-arenas@users.noreply.github.com> Date: Mon, 30 May 2022 13:34:59 -0500 Subject: [PATCH 5/5] Release version 0.4.1 --- pyworkforce/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyworkforce/_version.py b/pyworkforce/_version.py index 8f1231e..3d26edf 100644 --- a/pyworkforce/_version.py +++ b/pyworkforce/_version.py @@ -1 +1 @@ -__version__ = "0.4.1.dev0" +__version__ = "0.4.1"