diff --git a/.coveragerc b/.coveragerc index ca8eaac..e5d022e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,6 +3,9 @@ omit = ./tests/* ./venv/* ./docs/* + ./examples/* + */tests/* + setup.py [report] precision = 2 show_missing = True \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e8bb964 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python +python: + - 3.6 + - 3.7 + - 3.8 + - 3.9 +before_install: + - python --version + - pip install -U pip +install: + - pip install -r dev-requirements.txt +script: + - pytest pyworkforce/ --verbose --color=yes --assert=plain --cov-config=.coveragerc --cov=./ +after_success: + - codecov diff --git a/README.md b/README.md index 7c50ae1..3877093 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ + +[![Build Status](https://www.travis-ci.com/rodrigo-arenas/pyworkforce.svg?branch=main)](https://www.travis-ci.com/rodrigo-arenas/pyworkforce) +[![PyPI Version](https://badge.fury.io/py/pyworkforce.svg)](https://badge.fury.io/py/pyworkforce) +[![Python Version](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9-blue)](https://www.python.org/downloads/) + + + # pyworkforce -Common tools for workforce management, schedule and optimization problems built in top of tools like google's ortools +Common tools for workforce management, schedule and optimization problems built on top of packages like google's ortools and custom modules # Usage: diff --git a/dev-requirements.txt b/dev-requirements.txt index 32cb212..c548b95 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,6 @@ pytest==6.2.2 codecov==2.1.11 pytest-cov==2.11.1 -twine==2.4.7 \ No newline at end of file +twine==3.3.0 +numpy>=1.18.1 +ortools>=7.8.7959 diff --git a/pyworkforce/queuing/erlang.py b/pyworkforce/queuing/erlang.py index 24833de..11bee34 100644 --- a/pyworkforce/queuing/erlang.py +++ b/pyworkforce/queuing/erlang.py @@ -16,17 +16,17 @@ def __init__(self, transactions: float, aht: float, asa: float, :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 transactions <= 0: + raise ValueError("transactions can't be smaller or equals than 0") - if aht < 0: - raise ValueError("aht can't be smaller than 0") + if aht <= 0: + raise ValueError("aht can't be smaller or equals than 0") - if asa < 0: - raise ValueError("asa can't be smaller than 0") + if asa <= 0: + raise ValueError("asa can't be smaller or equals than 0") - if interval < 0: - raise ValueError("interval can't be smaller than 0") + if interval <= 0: + raise ValueError("interval can't be smaller or equals than 0") if shrinkage < 0 or shrinkage >= 1: raise ValueError("shrinkage must be between in the interval [0,1)") @@ -100,7 +100,7 @@ def required_positions(self, service_level: float, max_occupancy: float = 1.0): 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: + if max_occupancy < 0 or max_occupancy > 1: raise ValueError("max_occupancy must be between 0 and 1") positions = round(self.intensity + 1) diff --git a/tests/__init__.py b/pyworkforce/queuing/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to pyworkforce/queuing/tests/__init__.py diff --git a/pyworkforce/queuing/tests/test_erlang.py b/pyworkforce/queuing/tests/test_erlang.py new file mode 100644 index 0000000..2477182 --- /dev/null +++ b/pyworkforce/queuing/tests/test_erlang.py @@ -0,0 +1,92 @@ +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, 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_scale_positions_erlangc(): + erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3) + results = erlang.required_positions(service_level=0.8, max_occupancy=0.85) + positions = results['positions'] + service_level = erlang.service_level(positions=positions, scale_positions=True) + occupancy = erlang.achieved_occupancy(positions=positions, scale_positions=True) + waiting_probability = erlang.waiting_probability(positions=positions, scale_positions=True) + + 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_over_occupancy_erlangc(): + erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3) + results = erlang.required_positions(service_level=0.8, max_occupancy=0.7) + raw_positions = results['raw_positions'] + positions = results['positions'] + service_level = erlang.service_level(positions=positions, scale_positions=True) + occupancy = erlang.achieved_occupancy(positions=positions, scale_positions=True) + waiting_probability = erlang.waiting_probability(positions=positions, scale_positions=True) + + assert raw_positions == 15 + assert positions == 22 + assert round(service_level, 3) == 0.941 + assert round(occupancy, 3) == 0.667 + assert round(waiting_probability, 3) == 0.102 + + +def test_wrong_transactions_erlangc(): + with pytest.raises(Exception) as excinfo: + erlang = ErlangC(transactions=-20, asa=0.33, aht=3, interval=30, shrinkage=0.3) + assert str(excinfo.value) == "transactions can't be smaller or equals than 0" + + +def test_wrong_aht_erlangc(): + with pytest.raises(Exception) as excinfo: + erlang = ErlangC(transactions=100, asa=0.33, aht=-5, interval=30, shrinkage=0.3) + assert str(excinfo.value) == "aht can't be smaller or equals than 0" + + +def test_wrong_asa_erlangc(): + with pytest.raises(Exception) as excinfo: + erlang = ErlangC(transactions=100, asa=0, aht=5, interval=30, shrinkage=0.3) + assert str(excinfo.value) == "asa can't be smaller or equals than 0" + + +def test_wrong_interval_erlangc(): + with pytest.raises(Exception) as excinfo: + erlang = ErlangC(transactions=100, asa=10, aht=5, interval=-30, shrinkage=0.3) + assert str(excinfo.value) == "interval can't be smaller or equals than 0" + + +def test_wrong_shrinkage_erlangc(): + with pytest.raises(Exception) as excinfo: + erlang = ErlangC(transactions=100, asa=10, aht=5, interval=30, shrinkage=1) + assert str(excinfo.value) == "shrinkage must be between in the interval [0,1)" + + +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" + + +def test_wrong_max_occupancy_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=0.8, max_occupancy=1.2) + assert str(excinfo.value) == "max_occupancy must be between 0 and 1" diff --git a/pyworkforce/shifts/tests/__init__.py b/pyworkforce/shifts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyworkforce/shifts/tests/test_shifts.py b/pyworkforce/shifts/tests/test_shifts.py new file mode 100644 index 0000000..8e74bb3 --- /dev/null +++ b/pyworkforce/shifts/tests/test_shifts.py @@ -0,0 +1,61 @@ +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 + + +def test_infeasible_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=10, + max_shift_concurrency=20) + + solution = scheduler.solve() + + assert solution['status'] == 'INFEASIBLE' + assert 'cost' in solution + assert 'resources_shifts' in solution + assert solution['cost'] == -1 + assert len(solution['resources_shifts']) == 1 + assert solution['resources_shifts'][0]['day'] == -1 + assert solution['resources_shifts'][0]['shift'] == 'Unknown' + assert solution['resources_shifts'][0]['resources'] == -1 diff --git a/pyworkforce/shifts/tests/test_utils.py b/pyworkforce/shifts/tests/test_utils.py new file mode 100644 index 0000000..547fdd7 --- /dev/null +++ b/pyworkforce/shifts/tests/test_utils.py @@ -0,0 +1,30 @@ +import pytest +from pyworkforce.shifts.utils import check_positive_integer, check_positive_float + + +def test_check_positive_integer(): + assert check_positive_integer('my_val', 5) + + +def test_check_non_positive_integers(): + with pytest.raises(Exception) as excinfo: + result = check_positive_integer('my_val', -1) + assert str(excinfo.value) == "my_val must be a positive integer" + + with pytest.raises(Exception) as excinfo: + result = check_positive_integer('my_val2', 5.4) + assert str(excinfo.value) == "my_val2 must be a positive integer" + + +def test_check_positive_float(): + assert check_positive_float('my_val', 2.43) + + +def test_check_non_positive_floats(): + with pytest.raises(Exception) as excinfo: + result = check_positive_float('my_val', -45.3) + assert str(excinfo.value) == "my_val must be a positive float" + + with pytest.raises(Exception) as excinfo: + result = check_positive_float('my_val2', 80) + assert str(excinfo.value) == "my_val2 must be a positive float" diff --git a/setup.py b/setup.py index f81bb5c..ef68272 100644 --- a/setup.py +++ b/setup.py @@ -33,5 +33,6 @@ 'numpy>=1.18.1', 'ortools>=7.8.7959' ], + python_requires=">=3.6", include_package_data=True, ) diff --git a/tests/test_queing.py b/tests/test_queing.py deleted file mode 100644 index a26bdfe..0000000 --- a/tests/test_queing.py +++ /dev/null @@ -1,25 +0,0 @@ -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, 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 deleted file mode 100644 index d2cdd7a..0000000 --- a/tests/test_shifts.py +++ /dev/null @@ -1,30 +0,0 @@ -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