pyworkforce 0.5.1__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/PKG-INFO +66 -35
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/README.md +67 -51
- pyworkforce-0.5.2/pyworkforce/_version.py +1 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/queuing/erlang.py +81 -77
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/queuing/tests/test_erlang.py +35 -5
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/queuing/tests/test_multi_erlang.py +14 -6
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/rostering/binary_programming.py +26 -25
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/base.py +15 -13
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/shifts_selection.py +30 -28
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/utils/grid.py +1 -1
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/utils/tests/test_parameter_grid.py +13 -6
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce.egg-info/PKG-INFO +66 -35
- pyworkforce-0.5.2/pyworkforce.egg-info/requires.txt +4 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/setup.py +11 -12
- pyworkforce-0.5.1/pyworkforce/_version.py +0 -1
- pyworkforce-0.5.1/pyworkforce.egg-info/requires.txt +0 -4
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/LICENSE +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/queuing/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/queuing/tests/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/rostering/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/rostering/tests/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/rostering/tests/test_rostering.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/tests/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/tests/test_shifts.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/tests/test_utils.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/scheduling/utils.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/utils/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce/utils/tests/__init__.py +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce.egg-info/SOURCES.txt +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce.egg-info/dependency_links.txt +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/pyworkforce.egg-info/top_level.txt +0 -0
- {pyworkforce-0.5.1 → pyworkforce-0.5.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyworkforce
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Common tools for workforce management, schedule and optimization problems
|
|
5
5
|
Home-page: https://github.com/rodrigo-arenas/pyworkforce
|
|
6
6
|
Author: Rodrigo Arenas
|
|
@@ -11,66 +11,96 @@ Project-URL: Source Code, https://github.com/rodrigo-arenas/pyworkforce
|
|
|
11
11
|
Project-URL: Bug Tracker, https://github.com/rodrigo-arenas/pyworkforce/issues
|
|
12
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.
|
|
17
|
-
|
|
18
|
-
Requires-Python: >=3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.12,<3.15
|
|
19
18
|
Description-Content-Type: text/markdown
|
|
20
19
|
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.26.0
|
|
21
|
+
Requires-Dist: ortools>=9.12.4544
|
|
22
|
+
Requires-Dist: pandas>=2.2.0
|
|
23
|
+
Requires-Dist: joblib>=1.4.0
|
|
24
|
+
Dynamic: author
|
|
25
|
+
Dynamic: author-email
|
|
26
|
+
Dynamic: classifier
|
|
27
|
+
Dynamic: description
|
|
28
|
+
Dynamic: description-content-type
|
|
29
|
+
Dynamic: home-page
|
|
30
|
+
Dynamic: license
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
Dynamic: project-url
|
|
33
|
+
Dynamic: requires-dist
|
|
34
|
+
Dynamic: requires-python
|
|
35
|
+
Dynamic: summary
|
|
21
36
|
|
|
22
37
|
|
|
23
38
|
[](https://www.travis-ci.com/rodrigo-arenas/pyworkforce)
|
|
24
39
|
[](https://codecov.io/github/rodrigo-arenas/pyworkforce?branch=main)
|
|
25
40
|
[](https://badge.fury.io/py/pyworkforce)
|
|
26
|
-
[](https://www.python.org/downloads/)
|
|
27
42
|
|
|
28
43
|
|
|
29
44
|
# pyworkforce
|
|
30
|
-
|
|
45
|
+
Tools for workforce management problems such as queue staffing, shift scheduling,
|
|
46
|
+
rostering, and operations research optimization.
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
The full documentation is available at
|
|
49
|
+
[pyworkforce.readthedocs.io](https://pyworkforce.readthedocs.io/en/stable/).
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
Install pyworkforce
|
|
51
|
+
## Installation
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
We recommend installing pyworkforce in a virtual environment:
|
|
38
54
|
|
|
39
|
-
```
|
|
55
|
+
```bash
|
|
40
56
|
pip install pyworkforce
|
|
41
57
|
```
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
pyworkforce supports Python 3.12, 3.13, and 3.14.
|
|
60
|
+
|
|
61
|
+
If you are using Anaconda and run into installation issues, update the environment first:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
conda update --all
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
If the issue is related to OR-Tools, check the
|
|
68
|
+
[OR-Tools installation guide](https://github.com/google/or-tools#installation).
|
|
44
69
|
|
|
45
|
-
For
|
|
46
|
-
[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples)
|
|
70
|
+
For runnable examples, see the
|
|
71
|
+
[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples).
|
|
47
72
|
|
|
48
|
-
##
|
|
49
|
-
|
|
73
|
+
## What pyworkforce Does
|
|
74
|
+
|
|
75
|
+
pyworkforce is organized around three planning steps:
|
|
50
76
|
|
|
51
77
|
### Queuing
|
|
52
|
-
|
|
78
|
+
|
|
79
|
+
Use `pyworkforce.queuing` when you need to estimate how many resources are required
|
|
80
|
+
to handle incoming work, for example calls arriving at a call center. The current
|
|
81
|
+
implementation uses Erlang C assumptions: constant arrival rate, infinite queue,
|
|
82
|
+
and no customer dropout.
|
|
53
83
|
|
|
54
84
|

|
|
55
85
|
|
|
56
|
-
- **queuing.ErlangC:**
|
|
57
|
-
|
|
86
|
+
- **queuing.ErlangC:** Calculate staffing requirements and performance metrics for one queue scenario.
|
|
87
|
+
- **queuing.MultiErlangC:** Run multiple Erlang C scenarios from a parameter grid.
|
|
58
88
|
|
|
59
89
|
### Scheduling
|
|
60
90
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- **scheduling.MinRequiredResources
|
|
66
|
-
weighted amount of scheduled resources (optionally weighted by shift cost), it ensures that in all intervals, there are
|
|
67
|
-
never fewer resources shifted than the ones required per period.
|
|
91
|
+
Use `pyworkforce.scheduling` when you already know the required resources by time
|
|
92
|
+
interval and need to choose how many people to place on each predefined shift.
|
|
93
|
+
|
|
94
|
+
- **scheduling.MinAbsDifference:** Minimizes the total absolute difference between required and scheduled resources.
|
|
95
|
+
- **scheduling.MinRequiredResources:** Minimizes the total weighted number of scheduled resources while ensuring every interval is covered.
|
|
68
96
|
|
|
69
97
|
### Rostering
|
|
70
98
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
99
|
+
Use `pyworkforce.rostering` when you have named resources and need to assign them
|
|
100
|
+
to days and shifts while respecting rules such as banned shifts, rest days,
|
|
101
|
+
minimum working hours, and preferences.
|
|
102
|
+
|
|
103
|
+
- **rostering.MinHoursRoster:** Builds a resource-level roster that covers shift requirements with the minimum scheduled hours.
|
|
74
104
|
|
|
75
105
|
### Queue systems:
|
|
76
106
|
|
|
@@ -95,7 +125,8 @@ Output:
|
|
|
95
125
|
'waiting_probability': 0.1741319335950498}
|
|
96
126
|
```
|
|
97
127
|
|
|
98
|
-
If you want to run
|
|
128
|
+
If you want to run several scenarios at the same time, use `MultiErlangC`.
|
|
129
|
+
For example, this tries different service-level targets:
|
|
99
130
|
|
|
100
131
|
```python
|
|
101
132
|
from pyworkforce.queuing import MultiErlangC
|
|
@@ -143,13 +174,13 @@ A brief introduction can be found in this [medium post](https://towardsdatascien
|
|
|
143
174
|
```python
|
|
144
175
|
from pyworkforce.scheduling import MinAbsDifference, MinRequiredResources
|
|
145
176
|
|
|
146
|
-
# Rows are
|
|
177
|
+
# Rows are days. Each value is the number of required positions for one hour of the day.
|
|
147
178
|
required_resources = [
|
|
148
179
|
[9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7],
|
|
149
180
|
[13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8]
|
|
150
181
|
]
|
|
151
182
|
|
|
152
|
-
# Each
|
|
183
|
+
# Each shift has 24 entries, one per hour. Use 1 if the shift covers that hour, otherwise 0.
|
|
153
184
|
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],
|
|
154
185
|
"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],
|
|
155
186
|
"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],
|
|
@@ -2,54 +2,69 @@
|
|
|
2
2
|
[](https://www.travis-ci.com/rodrigo-arenas/pyworkforce)
|
|
3
3
|
[](https://codecov.io/github/rodrigo-arenas/pyworkforce?branch=main)
|
|
4
4
|
[](https://badge.fury.io/py/pyworkforce)
|
|
5
|
-
[](https://www.python.org/downloads/)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# pyworkforce
|
|
9
|
+
Tools for workforce management problems such as queue staffing, shift scheduling,
|
|
10
|
+
rostering, and operations research optimization.
|
|
11
|
+
|
|
12
|
+
The full documentation is available at
|
|
13
|
+
[pyworkforce.readthedocs.io](https://pyworkforce.readthedocs.io/en/stable/).
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
We recommend installing pyworkforce in a virtual environment:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install pyworkforce
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
pyworkforce supports Python 3.12, 3.13, and 3.14.
|
|
24
|
+
|
|
25
|
+
If you are using Anaconda and run into installation issues, update the environment first:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
conda update --all
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If the issue is related to OR-Tools, check the
|
|
32
|
+
[OR-Tools installation guide](https://github.com/google/or-tools#installation).
|
|
33
|
+
|
|
34
|
+
For runnable examples, see the
|
|
35
|
+
[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples).
|
|
36
|
+
|
|
37
|
+
## What pyworkforce Does
|
|
38
|
+
|
|
39
|
+
pyworkforce is organized around three planning steps:
|
|
40
|
+
|
|
41
|
+
### Queuing
|
|
42
|
+
|
|
43
|
+
Use `pyworkforce.queuing` when you need to estimate how many resources are required
|
|
44
|
+
to handle incoming work, for example calls arriving at a call center. The current
|
|
45
|
+
implementation uses Erlang C assumptions: constant arrival rate, infinite queue,
|
|
46
|
+
and no customer dropout.
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
- **queuing.ErlangC:** Calculate staffing requirements and performance metrics for one queue scenario.
|
|
51
|
+
- **queuing.MultiErlangC:** Run multiple Erlang C scenarios from a parameter grid.
|
|
52
|
+
|
|
53
|
+
### Scheduling
|
|
54
|
+
|
|
55
|
+
Use `pyworkforce.scheduling` when you already know the required resources by time
|
|
56
|
+
interval and need to choose how many people to place on each predefined shift.
|
|
57
|
+
|
|
58
|
+
- **scheduling.MinAbsDifference:** Minimizes the total absolute difference between required and scheduled resources.
|
|
59
|
+
- **scheduling.MinRequiredResources:** Minimizes the total weighted number of scheduled resources while ensuring every interval is covered.
|
|
60
|
+
|
|
61
|
+
### Rostering
|
|
62
|
+
|
|
63
|
+
Use `pyworkforce.rostering` when you have named resources and need to assign them
|
|
64
|
+
to days and shifts while respecting rules such as banned shifts, rest days,
|
|
65
|
+
minimum working hours, and preferences.
|
|
66
|
+
|
|
67
|
+
- **rostering.MinHoursRoster:** Builds a resource-level roster that covers shift requirements with the minimum scheduled hours.
|
|
53
68
|
|
|
54
69
|
### Queue systems:
|
|
55
70
|
|
|
@@ -74,7 +89,8 @@ Output:
|
|
|
74
89
|
'waiting_probability': 0.1741319335950498}
|
|
75
90
|
```
|
|
76
91
|
|
|
77
|
-
If you want to run
|
|
92
|
+
If you want to run several scenarios at the same time, use `MultiErlangC`.
|
|
93
|
+
For example, this tries different service-level targets:
|
|
78
94
|
|
|
79
95
|
```python
|
|
80
96
|
from pyworkforce.queuing import MultiErlangC
|
|
@@ -122,13 +138,13 @@ A brief introduction can be found in this [medium post](https://towardsdatascien
|
|
|
122
138
|
```python
|
|
123
139
|
from pyworkforce.scheduling import MinAbsDifference, MinRequiredResources
|
|
124
140
|
|
|
125
|
-
# Rows are
|
|
141
|
+
# Rows are days. Each value is the number of required positions for one hour of the day.
|
|
126
142
|
required_resources = [
|
|
127
143
|
[9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7],
|
|
128
144
|
[13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8]
|
|
129
145
|
]
|
|
130
146
|
|
|
131
|
-
# Each
|
|
147
|
+
# Each shift has 24 entries, one per hour. Use 1 if the shift covers that hour, otherwise 0.
|
|
132
148
|
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],
|
|
133
149
|
"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],
|
|
134
150
|
"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],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.2"
|
|
@@ -5,22 +5,22 @@ from joblib import Parallel, delayed
|
|
|
5
5
|
|
|
6
6
|
class ErlangC:
|
|
7
7
|
"""
|
|
8
|
-
Computes the number of positions required to
|
|
9
|
-
queue system
|
|
8
|
+
Computes the number of positions required to handle transactions in an
|
|
9
|
+
Erlang C queue system. Implementation inspired by:
|
|
10
10
|
https://lucidmanager.org/data-science/call-centre-workforce-planning-erlang-c-in-r/
|
|
11
11
|
|
|
12
12
|
Parameters
|
|
13
13
|
----------
|
|
14
14
|
transactions: float,
|
|
15
|
-
|
|
15
|
+
Total number of transactions arriving in the interval.
|
|
16
16
|
aht: float,
|
|
17
17
|
Average handling time of a transaction (minutes).
|
|
18
18
|
asa: float,
|
|
19
19
|
The required average speed of answer (minutes).
|
|
20
20
|
interval: int,
|
|
21
|
-
Interval length
|
|
21
|
+
Interval length, in minutes.
|
|
22
22
|
shrinkage: float,
|
|
23
|
-
|
|
23
|
+
Fraction of time that an operator unit is not available.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
def __init__(self, transactions: float, aht: float, asa: float,
|
|
@@ -46,51 +46,61 @@ class ErlangC:
|
|
|
46
46
|
self.aht = aht
|
|
47
47
|
self.interval = interval
|
|
48
48
|
self.asa = asa
|
|
49
|
-
self.intensity = (self.n_transactions / self.interval) * self.aht
|
|
50
|
-
self.shrinkage = shrinkage
|
|
51
|
-
|
|
52
|
-
def
|
|
49
|
+
self.intensity = (self.n_transactions / self.interval) * self.aht
|
|
50
|
+
self.shrinkage = shrinkage
|
|
51
|
+
|
|
52
|
+
def _productive_positions(self, positions: int, scale_positions: bool = False):
|
|
53
|
+
if scale_positions:
|
|
54
|
+
productive_positions = floor((1 - self.shrinkage) * positions)
|
|
55
|
+
else:
|
|
56
|
+
productive_positions = positions
|
|
57
|
+
|
|
58
|
+
if productive_positions <= 0:
|
|
59
|
+
raise ValueError("productive positions must be greater than 0")
|
|
60
|
+
|
|
61
|
+
if productive_positions <= self.intensity:
|
|
62
|
+
raise ValueError("positions must be greater than traffic intensity")
|
|
63
|
+
|
|
64
|
+
return productive_positions
|
|
65
|
+
|
|
66
|
+
def waiting_probability(self, positions: int, scale_positions: bool = False):
|
|
53
67
|
"""
|
|
54
|
-
Returns the probability
|
|
68
|
+
Returns the probability that a transaction waits in queue.
|
|
55
69
|
|
|
56
70
|
Parameters
|
|
57
71
|
----------
|
|
58
|
-
positions: int,
|
|
59
|
-
The number of positions to
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
positions: int,
|
|
73
|
+
The number of positions available to handle transactions.
|
|
74
|
+
Productive positions must be greater than traffic intensity.
|
|
75
|
+
scale_positions: bool, default=False
|
|
76
|
+
Set to True when ``positions`` includes shrinkage.
|
|
62
77
|
|
|
63
78
|
"""
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
erlang_b_inverse = 1
|
|
71
|
-
for position in range(1, productive_positions + 1):
|
|
72
|
-
erlang_b_inverse = 1 + (erlang_b_inverse * position / self.intensity)
|
|
80
|
+
productive_positions = self._productive_positions(positions, scale_positions)
|
|
81
|
+
|
|
82
|
+
erlang_b_inverse = 1
|
|
83
|
+
for position in range(1, productive_positions + 1):
|
|
84
|
+
erlang_b_inverse = 1 + (erlang_b_inverse * position / self.intensity)
|
|
73
85
|
|
|
74
86
|
erlang_b = 1 / erlang_b_inverse
|
|
75
87
|
return productive_positions * erlang_b / (productive_positions - self.intensity * (1 - erlang_b))
|
|
76
88
|
|
|
77
89
|
def service_level(self, positions: int, scale_positions: bool = False):
|
|
78
90
|
"""
|
|
79
|
-
Returns the expected service level
|
|
91
|
+
Returns the expected service level for a number of positions.
|
|
80
92
|
|
|
81
93
|
Parameters
|
|
82
94
|
----------
|
|
83
95
|
|
|
84
|
-
positions: int,
|
|
85
|
-
The number of positions
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
positions: int,
|
|
97
|
+
The number of positions available to handle transactions.
|
|
98
|
+
Productive positions must be greater than traffic intensity.
|
|
99
|
+
scale_positions: bool, default = False
|
|
100
|
+
Set to True when ``positions`` includes shrinkage.
|
|
88
101
|
|
|
89
102
|
"""
|
|
90
|
-
|
|
91
|
-
productive_positions = floor((1 - self.shrinkage) * positions)
|
|
92
|
-
else:
|
|
93
|
-
productive_positions = positions
|
|
103
|
+
productive_positions = self._productive_positions(positions, scale_positions)
|
|
94
104
|
|
|
95
105
|
probability_wait = self.waiting_probability(productive_positions, scale_positions=False)
|
|
96
106
|
exponential = exp(-(productive_positions - self.intensity) * (self.asa / self.aht))
|
|
@@ -98,57 +108,58 @@ class ErlangC:
|
|
|
98
108
|
|
|
99
109
|
def achieved_occupancy(self, positions: int, scale_positions: bool = False):
|
|
100
110
|
"""
|
|
101
|
-
Returns the expected occupancy of positions
|
|
111
|
+
Returns the expected occupancy of positions.
|
|
102
112
|
|
|
103
113
|
Parameters
|
|
104
114
|
----------
|
|
105
115
|
|
|
106
|
-
positions: int,
|
|
107
|
-
The number of raw positions
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
positions: int,
|
|
117
|
+
The number of raw positions.
|
|
118
|
+
Productive positions must be greater than traffic intensity.
|
|
119
|
+
scale_positions: bool, default=False
|
|
120
|
+
Set to True when ``positions`` includes shrinkage.
|
|
110
121
|
|
|
111
122
|
"""
|
|
112
|
-
|
|
113
|
-
productive_positions = floor((1 - self.shrinkage) * positions)
|
|
114
|
-
else:
|
|
115
|
-
productive_positions = positions
|
|
123
|
+
productive_positions = self._productive_positions(positions, scale_positions)
|
|
116
124
|
|
|
117
125
|
return self.intensity / productive_positions
|
|
118
126
|
|
|
119
127
|
def required_positions(self, service_level: float, max_occupancy: float = 1.0):
|
|
120
128
|
"""
|
|
121
|
-
Computes the
|
|
129
|
+
Computes the required positions for a target service level.
|
|
122
130
|
|
|
123
131
|
Parameters
|
|
124
132
|
----------
|
|
125
133
|
|
|
126
134
|
service_level: float,
|
|
127
|
-
Target service level
|
|
128
|
-
max_occupancy: float,
|
|
129
|
-
The maximum fraction of time that a transaction can occupy a position
|
|
135
|
+
Target service level.
|
|
136
|
+
max_occupancy: float,
|
|
137
|
+
The maximum fraction of time that a transaction can occupy a position.
|
|
138
|
+
Must be greater than 0 and less than or equal to 1.
|
|
130
139
|
|
|
131
140
|
Returns
|
|
132
141
|
-------
|
|
133
142
|
|
|
134
143
|
raw_positions: int,
|
|
135
|
-
|
|
144
|
+
Required positions before applying shrinkage.
|
|
136
145
|
positions: int,
|
|
137
|
-
|
|
146
|
+
Positions needed after applying shrinkage.
|
|
138
147
|
service_level: float,
|
|
139
|
-
|
|
140
|
-
before the asa time
|
|
148
|
+
Fraction of transactions expected to reach a position before the target ASA.
|
|
141
149
|
occupancy: float,
|
|
142
|
-
|
|
150
|
+
Expected occupancy of positions.
|
|
143
151
|
waiting_probability: float,
|
|
144
|
-
|
|
152
|
+
Probability that a transaction waits in queue.
|
|
145
153
|
"""
|
|
146
154
|
|
|
147
155
|
if service_level < 0 or service_level > 1:
|
|
148
156
|
raise ValueError("service_level must be between 0 and 1")
|
|
149
157
|
|
|
150
|
-
if max_occupancy < 0 or max_occupancy > 1:
|
|
151
|
-
raise ValueError("max_occupancy must be between 0 and 1")
|
|
158
|
+
if max_occupancy < 0 or max_occupancy > 1:
|
|
159
|
+
raise ValueError("max_occupancy must be between 0 and 1")
|
|
160
|
+
|
|
161
|
+
if max_occupancy == 0:
|
|
162
|
+
raise ValueError("max_occupancy must be greater than 0")
|
|
152
163
|
|
|
153
164
|
positions = round(self.intensity + 1)
|
|
154
165
|
achieved_service_level = self.service_level(positions, scale_positions=False)
|
|
@@ -177,47 +188,40 @@ class ErlangC:
|
|
|
177
188
|
|
|
178
189
|
class MultiErlangC:
|
|
179
190
|
"""
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
191
|
+
Runs Erlang C calculations over multiple parameter combinations.
|
|
192
|
+
|
|
193
|
+
This class uses joblib's ``Parallel`` to evaluate every combination from
|
|
194
|
+
``param_grid`` and the method-specific argument grid. Its interface is
|
|
195
|
+
inspired by scikit-learn's grid search utilities.
|
|
184
196
|
|
|
185
197
|
Parameters
|
|
186
198
|
----------
|
|
187
199
|
|
|
188
200
|
param_grid: dict,
|
|
189
|
-
Dictionary with
|
|
190
|
-
expected parameter and
|
|
201
|
+
Dictionary with :class:`ErlangC` initialization parameters. Each key must be an
|
|
202
|
+
expected parameter, and each value must be a list of options to iterate over.
|
|
191
203
|
example: {"transactions": [100, 200], "aht": [3], "interval": [30], "asa": [20 / 60], "shrinkage": [0.3]}
|
|
192
204
|
n_jobs: int, default=2
|
|
193
|
-
|
|
205
|
+
Maximum number of concurrently running jobs.
|
|
194
206
|
If -1 all CPUs are used. If 1 is given, no parallel computing code is used at all, which is useful for debugging.
|
|
195
207
|
For n_jobs below -1, (n_cpus + 1 + n_jobs) are used. Thus for n_jobs = -2, all CPUs but one are used.
|
|
196
208
|
None is a marker for ‘unset’ that will be interpreted as n_jobs=1 (sequential execution)
|
|
197
209
|
unless the call is performed under a parallel_backend() context manager that sets another value for n_jobs.
|
|
198
210
|
pre_dispatch: {"all", int, or expression}, default='2 * n_jobs'
|
|
199
|
-
|
|
211
|
+
Number of task batches to pre-dispatch. Default is ``2*n_jobs``.
|
|
200
212
|
See joblib's documentation for more details: https://joblib.readthedocs.io/en/latest/generated/joblib.Parallel.html
|
|
201
213
|
|
|
202
214
|
Attributes
|
|
203
215
|
----------
|
|
204
216
|
|
|
205
217
|
waiting_probability_params: list[tuple],
|
|
206
|
-
|
|
207
|
-
arguments_grid for waiting_probability method,corresponding to the same order returned
|
|
208
|
-
by the MultiErlangC.waiting_probability method.
|
|
218
|
+
Parameters used for each ``waiting_probability`` result, in result order.
|
|
209
219
|
service_level_params: list[tuple],
|
|
210
|
-
|
|
211
|
-
arguments_grid for service_level method,corresponding to the same order returned
|
|
212
|
-
by the MultiErlangC.service_level method.
|
|
220
|
+
Parameters used for each ``service_level`` result, in result order.
|
|
213
221
|
achieved_occupancy_params: list[tuple],
|
|
214
|
-
|
|
215
|
-
arguments_grid for achieved_occupancy method,corresponding to the same order returned
|
|
216
|
-
by the MultiErlangC.achieved_occupancy method.
|
|
222
|
+
Parameters used for each ``achieved_occupancy`` result, in result order.
|
|
217
223
|
required_positions_params: list[tuple],
|
|
218
|
-
|
|
219
|
-
arguments_grid for required_positions method,corresponding to the same order returned
|
|
220
|
-
by the MultiErlangC.required_positions method.
|
|
224
|
+
Parameters used for each ``required_positions`` result, in result order.
|
|
221
225
|
"""
|
|
222
226
|
|
|
223
227
|
def __init__(self, param_grid: dict, n_jobs: int = 2, pre_dispatch: str = '2 * n_jobs'):
|
|
@@ -347,8 +351,8 @@ class MultiErlangC:
|
|
|
347
351
|
if len(solutions) < 1: # noqa
|
|
348
352
|
raise ValueError("Could not find any solution, make sure the param_grid is defined correctly")
|
|
349
353
|
|
|
350
|
-
if len(solutions) != combinations:
|
|
351
|
-
raise ValueError('Inconsistent results. Expected {} '
|
|
352
|
-
'solutions, got {}'
|
|
353
|
-
.format(
|
|
354
|
-
len(solutions))) # noqa
|
|
354
|
+
if len(solutions) != combinations:
|
|
355
|
+
raise ValueError('Inconsistent results. Expected {} '
|
|
356
|
+
'solutions, got {}'
|
|
357
|
+
.format(combinations,
|
|
358
|
+
len(solutions))) # noqa
|
|
@@ -85,8 +85,38 @@ def test_wrong_service_level_erlangc():
|
|
|
85
85
|
assert str(excinfo.value) == "service_level must be between 0 and 1"
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def test_wrong_max_occupancy_erlangc():
|
|
89
|
-
erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3)
|
|
90
|
-
with pytest.raises(Exception) as excinfo:
|
|
91
|
-
results = erlang.required_positions(service_level=0.8, max_occupancy=1.2)
|
|
92
|
-
assert str(excinfo.value) == "max_occupancy must be between 0 and 1"
|
|
88
|
+
def test_wrong_max_occupancy_erlangc():
|
|
89
|
+
erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3)
|
|
90
|
+
with pytest.raises(Exception) as excinfo:
|
|
91
|
+
results = erlang.required_positions(service_level=0.8, max_occupancy=1.2)
|
|
92
|
+
assert str(excinfo.value) == "max_occupancy must be between 0 and 1"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_zero_max_occupancy_erlangc():
|
|
96
|
+
erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.3)
|
|
97
|
+
with pytest.raises(Exception) as excinfo:
|
|
98
|
+
erlang.required_positions(service_level=0.8, max_occupancy=0)
|
|
99
|
+
assert str(excinfo.value) == "max_occupancy must be greater than 0"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_waiting_probability_requires_productive_positions():
|
|
103
|
+
erlang = ErlangC(transactions=1, asa=0.33, aht=1, interval=30, shrinkage=0.9)
|
|
104
|
+
with pytest.raises(Exception) as excinfo:
|
|
105
|
+
erlang.waiting_probability(positions=1, scale_positions=True)
|
|
106
|
+
assert str(excinfo.value) == "productive positions must be greater than 0"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_erlang_methods_require_stable_system():
|
|
110
|
+
erlang = ErlangC(transactions=100, asa=0.33, aht=3, interval=30, shrinkage=0.0)
|
|
111
|
+
|
|
112
|
+
with pytest.raises(Exception) as excinfo:
|
|
113
|
+
erlang.waiting_probability(positions=10)
|
|
114
|
+
assert str(excinfo.value) == "positions must be greater than traffic intensity"
|
|
115
|
+
|
|
116
|
+
with pytest.raises(Exception) as excinfo:
|
|
117
|
+
erlang.service_level(positions=10)
|
|
118
|
+
assert str(excinfo.value) == "positions must be greater than traffic intensity"
|
|
119
|
+
|
|
120
|
+
with pytest.raises(Exception) as excinfo:
|
|
121
|
+
erlang.achieved_occupancy(positions=10)
|
|
122
|
+
assert str(excinfo.value) == "positions must be greater than traffic intensity"
|
|
@@ -86,10 +86,18 @@ def test_expected_multierlangc_wrong_arguments():
|
|
|
86
86
|
"ErlangC.required_positions() missing 1 required positional argument: 'service_level'"]
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def test_multierlangc_wrong_grid():
|
|
90
|
-
param_grid = {"transactions": 100, "asa": [0.33], "aht": [3], "interval": [30], "shrinkage": [0.3]}
|
|
91
|
-
|
|
92
|
-
with pytest.raises(Exception) as excinfo:
|
|
93
|
-
results = MultiErlangC(param_grid=param_grid)
|
|
94
|
-
assert str(excinfo.value) == "Parameter grid value is not iterable (key='transactions', value=100)"
|
|
89
|
+
def test_multierlangc_wrong_grid():
|
|
90
|
+
param_grid = {"transactions": 100, "asa": [0.33], "aht": [3], "interval": [30], "shrinkage": [0.3]}
|
|
91
|
+
|
|
92
|
+
with pytest.raises(Exception) as excinfo:
|
|
93
|
+
results = MultiErlangC(param_grid=param_grid)
|
|
94
|
+
assert str(excinfo.value) == "Parameter grid value is not iterable (key='transactions', value=100)"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_multierlangc_check_solutions_expected_combinations():
|
|
98
|
+
erlang = MultiErlangC(param_grid={"transactions": [100], "asa": [0.33], "aht": [3], "interval": [30]})
|
|
99
|
+
|
|
100
|
+
with pytest.raises(Exception) as excinfo:
|
|
101
|
+
erlang._check_solutions([{}], combinations=2)
|
|
102
|
+
assert str(excinfo.value) == "Inconsistent results. Expected 2 solutions, got 1"
|
|
95
103
|
|
|
@@ -5,48 +5,49 @@ from ortools.sat.python import cp_model
|
|
|
5
5
|
class MinHoursRoster:
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
Assigns named resources to required positions by day and shift.
|
|
9
|
+
|
|
10
|
+
The solver supports restrictions such as banned shifts, non-sequential
|
|
11
|
+
shifts, rest days, and minimum working hours. It also supports soft shift
|
|
12
|
+
preferences. The objective is to minimize total scheduled hours, optionally
|
|
13
|
+
weighted by resource shift preferences.
|
|
13
14
|
|
|
14
15
|
Parameters
|
|
15
16
|
----------
|
|
16
17
|
|
|
17
18
|
num_days: int,
|
|
18
|
-
Number of days
|
|
19
|
+
Number of days to schedule.
|
|
19
20
|
resources: list[str],
|
|
20
|
-
Resources available to
|
|
21
|
+
Resources available to schedule.
|
|
21
22
|
shifts: list,
|
|
22
|
-
|
|
23
|
+
List of shift names.
|
|
23
24
|
shifts_hours: list,
|
|
24
|
-
Array of size [shifts] with the
|
|
25
|
+
Array of size ``[shifts]`` with the duration of each shift.
|
|
25
26
|
min_working_hours: int,
|
|
26
|
-
Minimum working hours per resource in the horizon
|
|
27
|
+
Minimum working hours per resource in the planning horizon.
|
|
27
28
|
banned_shifts: list[dict]
|
|
28
|
-
Each element {"resource":
|
|
29
|
-
that the resource
|
|
29
|
+
Each element has the form ``{"resource": resource_id, "shift": shift_name, "day": day_number}``
|
|
30
|
+
and marks a shift that the resource cannot work on that day.
|
|
30
31
|
example: banned_shifts": [{"resource":"e.johnston@randatmail.com", "shift": "Night", "day": 0}],
|
|
31
32
|
max_resting: int,
|
|
32
|
-
Maximum number of resting days per resource in the
|
|
33
|
+
Maximum number of resting days per resource in the planning horizon.
|
|
33
34
|
required_resources: dict[list]
|
|
34
|
-
Each key
|
|
35
|
-
|
|
35
|
+
Each key must be a shift name, and each value must be a list of length
|
|
36
|
+
``num_days`` with the required resources for that shift each day.
|
|
36
37
|
non_sequential_shifts: List[dict]
|
|
37
|
-
Each element must have the form {"origin": first_shift, "destination": second_shift}
|
|
38
|
-
to
|
|
38
|
+
Each element must have the form ``{"origin": first_shift, "destination": second_shift}``
|
|
39
|
+
to prevent ``destination`` from being assigned the day after ``origin``.
|
|
39
40
|
example: [{"origin":"Night", "destination":"Morning"}]
|
|
40
41
|
resources_preferences: list[dict]
|
|
41
|
-
Each element must have the form {"resource":
|
|
42
|
-
|
|
42
|
+
Each element must have the form ``{"resource": resource_id, "shift": shift_name}``
|
|
43
|
+
and indicates that the resource prefers that shift.
|
|
43
44
|
resources_prioritization: list[dict], default=None
|
|
44
|
-
Each element must have the form {"resource":
|
|
45
|
-
|
|
45
|
+
Each element must have the form ``{"resource": resource_id, "weight": weight}``
|
|
46
|
+
and represents the relative importance of that resource's preferences.
|
|
46
47
|
max_search_time: float, default = 240
|
|
47
|
-
Maximum time in seconds to search for a solution
|
|
48
|
+
Maximum time, in seconds, to search for a solution.
|
|
48
49
|
num_search_workers: int, default = 2
|
|
49
|
-
Number of workers to search for a solution
|
|
50
|
+
Number of workers used to search for a solution.
|
|
50
51
|
"""
|
|
51
52
|
|
|
52
53
|
def __init__(self, num_days: int,
|
|
@@ -108,7 +109,7 @@ class MinHoursRoster:
|
|
|
108
109
|
|
|
109
110
|
# Constrains
|
|
110
111
|
|
|
111
|
-
# The number of
|
|
112
|
+
# The number of scheduled resources must be greater than or equal to the requirement for each day and shift
|
|
112
113
|
for d in range(self._num_days):
|
|
113
114
|
for s in range(self.num_shifts):
|
|
114
115
|
sch_model.Add(sum(shifted_resource[n][d][s] for n in range(self.num_resource))
|
|
@@ -144,7 +145,7 @@ class MinHoursRoster:
|
|
|
144
145
|
shifted_resource[n][d + 1][j]
|
|
145
146
|
for j in range(self.num_shifts)) <= 1)
|
|
146
147
|
|
|
147
|
-
#
|
|
148
|
+
# Resources cannot be assigned to banned shifts
|
|
148
149
|
if self.banned_shifts is not None:
|
|
149
150
|
for ban in self.banned_shifts:
|
|
150
151
|
resource_idx = self.resources.index(ban['resource'])
|
|
@@ -13,31 +13,33 @@ class BaseShiftScheduler:
|
|
|
13
13
|
num_search_workers=2):
|
|
14
14
|
|
|
15
15
|
"""
|
|
16
|
-
Base class
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
Base class for shift scheduling problems.
|
|
17
|
+
|
|
18
|
+
Scheduling finds the number of resources (agents, operators, doctors, etc.)
|
|
19
|
+
to allocate to each shift, based on predefined resource requirements by
|
|
20
|
+
period of the day.
|
|
21
21
|
|
|
22
22
|
Parameters
|
|
23
23
|
----------
|
|
24
24
|
|
|
25
25
|
num_days: int,
|
|
26
|
-
Number of days
|
|
26
|
+
Number of days to schedule.
|
|
27
27
|
periods: int,
|
|
28
|
-
Number of working periods in a day
|
|
28
|
+
Number of working periods in a day.
|
|
29
29
|
shifts_coverage: dict,
|
|
30
|
-
|
|
30
|
+
Dictionary of the form ``{"shift_name": shift_array}``, where each
|
|
31
|
+
``shift_array`` has length ``periods`` and uses 1 when the shift
|
|
32
|
+
covers a period, otherwise 0.
|
|
31
33
|
required_resources: list,
|
|
32
|
-
Array of size [days, periods]
|
|
34
|
+
Array of size ``[days, periods]``.
|
|
33
35
|
max_period_concurrency: int,
|
|
34
|
-
Maximum resources
|
|
36
|
+
Maximum resources allowed in any period and day.
|
|
35
37
|
max_shift_concurrency: int,
|
|
36
|
-
|
|
38
|
+
Maximum resources allowed in the same shift.
|
|
37
39
|
max_search_time: float, default = 240
|
|
38
|
-
Maximum time in seconds to search for a solution
|
|
40
|
+
Maximum time, in seconds, to search for a solution.
|
|
39
41
|
num_search_workers: int, default = 2
|
|
40
|
-
Number of workers to search for a solution
|
|
42
|
+
Number of workers used to search for a solution.
|
|
41
43
|
"""
|
|
42
44
|
|
|
43
45
|
is_valid_num_days = check_positive_integer("num_days", num_days)
|
|
@@ -15,29 +15,30 @@ class MinAbsDifference(BaseShiftScheduler):
|
|
|
15
15
|
num_search_workers=2,
|
|
16
16
|
*args, **kwargs):
|
|
17
17
|
"""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
per period and the actual scheduling found by the solver
|
|
18
|
+
Minimizes the total absolute difference between required resources per
|
|
19
|
+
period and resources scheduled by the solver.
|
|
21
20
|
|
|
22
21
|
Parameters
|
|
23
22
|
----------
|
|
24
23
|
|
|
25
24
|
num_days: int,
|
|
26
|
-
Number of days
|
|
25
|
+
Number of days to schedule.
|
|
27
26
|
periods: int,
|
|
28
|
-
Number of working periods in a day
|
|
27
|
+
Number of working periods in a day.
|
|
29
28
|
shifts_coverage: dict,
|
|
30
|
-
|
|
29
|
+
Dictionary of the form ``{"shift_name": shift_array}``, where each
|
|
30
|
+
``shift_array`` has length ``periods`` and uses 1 when the shift
|
|
31
|
+
covers a period, otherwise 0.
|
|
31
32
|
required_resources: list,
|
|
32
|
-
Array of size [days, periods]
|
|
33
|
+
Array of size ``[days, periods]``.
|
|
33
34
|
max_period_concurrency: int,
|
|
34
|
-
Maximum resources
|
|
35
|
+
Maximum resources allowed in any period and day.
|
|
35
36
|
max_shift_concurrency: int,
|
|
36
|
-
|
|
37
|
+
Maximum resources allowed in the same shift.
|
|
37
38
|
max_search_time: float, default = 240
|
|
38
|
-
Maximum time in seconds to search for a solution
|
|
39
|
+
Maximum time, in seconds, to search for a solution.
|
|
39
40
|
num_search_workers: int, default = 2
|
|
40
|
-
Number of workers to search for a solution
|
|
41
|
+
Number of workers used to search for a solution.
|
|
41
42
|
"""
|
|
42
43
|
|
|
43
44
|
super().__init__(num_days,
|
|
@@ -56,8 +57,8 @@ class MinAbsDifference(BaseShiftScheduler):
|
|
|
56
57
|
Returns
|
|
57
58
|
-------
|
|
58
59
|
solution: dict,
|
|
59
|
-
Dictionary with
|
|
60
|
-
|
|
60
|
+
Dictionary with optimization status, scheduled resources by day and
|
|
61
|
+
shift, and final objective value.
|
|
61
62
|
"""
|
|
62
63
|
sch_model = cp_model.CpModel()
|
|
63
64
|
|
|
@@ -90,7 +91,7 @@ class MinAbsDifference(BaseShiftScheduler):
|
|
|
90
91
|
- sum(resources[d][s] * self.shifts_coverage_matrix[s][p]
|
|
91
92
|
for s in range(self.num_shifts))))
|
|
92
93
|
|
|
93
|
-
# Total programmed resources
|
|
94
|
+
# Total programmed resources must be less than or equal to max_period_concurrency for each day and period
|
|
94
95
|
for d in range(self.num_days):
|
|
95
96
|
for p in range(self.num_periods):
|
|
96
97
|
sch_model.Add(
|
|
@@ -139,32 +140,33 @@ class MinRequiredResources(BaseShiftScheduler):
|
|
|
139
140
|
num_search_workers: int = 2,
|
|
140
141
|
*args, **kwargs):
|
|
141
142
|
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
fewer resources shifted than the ones required per period
|
|
143
|
+
Minimizes the weighted number of scheduled resources while ensuring that
|
|
144
|
+
every period has at least the required number of resources.
|
|
145
145
|
|
|
146
146
|
Parameters
|
|
147
147
|
----------
|
|
148
148
|
|
|
149
149
|
num_days: int,
|
|
150
|
-
Number of days
|
|
150
|
+
Number of days to schedule.
|
|
151
151
|
periods: int,
|
|
152
|
-
Number of working periods in a day
|
|
152
|
+
Number of working periods in a day.
|
|
153
153
|
shifts_coverage: dict,
|
|
154
|
-
|
|
154
|
+
Dictionary of the form ``{"shift_name": shift_array}``, where each
|
|
155
|
+
``shift_array`` has length ``periods`` and uses 1 when the shift
|
|
156
|
+
covers a period, otherwise 0.
|
|
155
157
|
required_resources: list,
|
|
156
|
-
Array of size [days, periods]
|
|
158
|
+
Array of size ``[days, periods]``.
|
|
157
159
|
max_period_concurrency: int,
|
|
158
|
-
Maximum resources
|
|
160
|
+
Maximum resources allowed in any period and day.
|
|
159
161
|
max_shift_concurrency: int,
|
|
160
|
-
|
|
162
|
+
Maximum resources allowed in the same shift.
|
|
161
163
|
cost_dict: dict, default = None
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
Dictionary of the form ``{shift: cost_value}``. It must contain the
|
|
165
|
+
same shifts as ``shifts_coverage``.
|
|
164
166
|
max_search_time: float, default = 240
|
|
165
|
-
Maximum time in seconds to search for a solution
|
|
167
|
+
Maximum time, in seconds, to search for a solution.
|
|
166
168
|
num_search_workers: int, default = 2
|
|
167
|
-
Number of workers to search for a solution
|
|
169
|
+
Number of workers used to search for a solution.
|
|
168
170
|
"""
|
|
169
171
|
|
|
170
172
|
super().__init__(num_days,
|
|
@@ -214,7 +216,7 @@ class MinRequiredResources(BaseShiftScheduler):
|
|
|
214
216
|
sch_model.Add(sum(resources[d][s] * self.shifts_coverage_matrix[s][p]
|
|
215
217
|
for s in range(self.num_shifts)) >= self.required_resources[d][p])
|
|
216
218
|
|
|
217
|
-
# Total programmed resources
|
|
219
|
+
# Total programmed resources must be less than or equal to max_period_concurrency for each day and period
|
|
218
220
|
for d in range(self.num_days):
|
|
219
221
|
for p in range(self.num_periods):
|
|
220
222
|
sch_model.Add(
|
|
@@ -98,7 +98,7 @@ class ParameterGrid:
|
|
|
98
98
|
# Reverse so most frequent cycling parameter comes first
|
|
99
99
|
keys, values_lists = zip(*sorted(sub_grid.items())[::-1])
|
|
100
100
|
sizes = [len(v_list) for v_list in values_lists]
|
|
101
|
-
total = np.
|
|
101
|
+
total = np.prod(sizes)
|
|
102
102
|
|
|
103
103
|
if ind >= total:
|
|
104
104
|
# Try the next grid
|
|
@@ -8,7 +8,7 @@ def assert_grid_iter_equals_getitem(grid):
|
|
|
8
8
|
assert list(grid) == [grid[i] for i in range(len(grid))]
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def test_parameter_grid():
|
|
11
|
+
def test_parameter_grid():
|
|
12
12
|
"""
|
|
13
13
|
Test taken from scikit-learn
|
|
14
14
|
"""
|
|
@@ -39,13 +39,20 @@ def test_parameter_grid():
|
|
|
39
39
|
assert len(empty) == 1
|
|
40
40
|
assert list(empty) == [{}]
|
|
41
41
|
assert_grid_iter_equals_getitem(empty)
|
|
42
|
-
with pytest.raises(IndexError):
|
|
43
|
-
empty[1]
|
|
44
|
-
|
|
45
|
-
has_empty = ParameterGrid([{'C': [1, 10]}, {}, {'C': [.5]}])
|
|
42
|
+
with pytest.raises(IndexError):
|
|
43
|
+
empty[1]
|
|
44
|
+
|
|
45
|
+
has_empty = ParameterGrid([{'C': [1, 10]}, {}, {'C': [.5]}])
|
|
46
46
|
assert len(has_empty) == 4
|
|
47
47
|
assert list(has_empty) == [{'C': 1}, {'C': 10}, {}, {'C': .5}]
|
|
48
|
-
assert_grid_iter_equals_getitem(has_empty)
|
|
48
|
+
assert_grid_iter_equals_getitem(has_empty)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_parameter_grid_getitem_uses_numpy_2_compatible_product():
|
|
52
|
+
grid = ParameterGrid({"a": [1, 2], "b": [3, 4]})
|
|
53
|
+
|
|
54
|
+
assert grid[0] == {"a": 1, "b": 3}
|
|
55
|
+
assert grid[3] == {"a": 2, "b": 4}
|
|
49
56
|
|
|
50
57
|
|
|
51
58
|
def test_non_iterable_parameter_grid():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyworkforce
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Common tools for workforce management, schedule and optimization problems
|
|
5
5
|
Home-page: https://github.com/rodrigo-arenas/pyworkforce
|
|
6
6
|
Author: Rodrigo Arenas
|
|
@@ -11,66 +11,96 @@ Project-URL: Source Code, https://github.com/rodrigo-arenas/pyworkforce
|
|
|
11
11
|
Project-URL: Bug Tracker, https://github.com/rodrigo-arenas/pyworkforce/issues
|
|
12
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.
|
|
17
|
-
|
|
18
|
-
Requires-Python: >=3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.12,<3.15
|
|
19
18
|
Description-Content-Type: text/markdown
|
|
20
19
|
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.26.0
|
|
21
|
+
Requires-Dist: ortools>=9.12.4544
|
|
22
|
+
Requires-Dist: pandas>=2.2.0
|
|
23
|
+
Requires-Dist: joblib>=1.4.0
|
|
24
|
+
Dynamic: author
|
|
25
|
+
Dynamic: author-email
|
|
26
|
+
Dynamic: classifier
|
|
27
|
+
Dynamic: description
|
|
28
|
+
Dynamic: description-content-type
|
|
29
|
+
Dynamic: home-page
|
|
30
|
+
Dynamic: license
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
Dynamic: project-url
|
|
33
|
+
Dynamic: requires-dist
|
|
34
|
+
Dynamic: requires-python
|
|
35
|
+
Dynamic: summary
|
|
21
36
|
|
|
22
37
|
|
|
23
38
|
[](https://www.travis-ci.com/rodrigo-arenas/pyworkforce)
|
|
24
39
|
[](https://codecov.io/github/rodrigo-arenas/pyworkforce?branch=main)
|
|
25
40
|
[](https://badge.fury.io/py/pyworkforce)
|
|
26
|
-
[](https://www.python.org/downloads/)
|
|
27
42
|
|
|
28
43
|
|
|
29
44
|
# pyworkforce
|
|
30
|
-
|
|
45
|
+
Tools for workforce management problems such as queue staffing, shift scheduling,
|
|
46
|
+
rostering, and operations research optimization.
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
The full documentation is available at
|
|
49
|
+
[pyworkforce.readthedocs.io](https://pyworkforce.readthedocs.io/en/stable/).
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
Install pyworkforce
|
|
51
|
+
## Installation
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
We recommend installing pyworkforce in a virtual environment:
|
|
38
54
|
|
|
39
|
-
```
|
|
55
|
+
```bash
|
|
40
56
|
pip install pyworkforce
|
|
41
57
|
```
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
pyworkforce supports Python 3.12, 3.13, and 3.14.
|
|
60
|
+
|
|
61
|
+
If you are using Anaconda and run into installation issues, update the environment first:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
conda update --all
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
If the issue is related to OR-Tools, check the
|
|
68
|
+
[OR-Tools installation guide](https://github.com/google/or-tools#installation).
|
|
44
69
|
|
|
45
|
-
For
|
|
46
|
-
[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples)
|
|
70
|
+
For runnable examples, see the
|
|
71
|
+
[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples).
|
|
47
72
|
|
|
48
|
-
##
|
|
49
|
-
|
|
73
|
+
## What pyworkforce Does
|
|
74
|
+
|
|
75
|
+
pyworkforce is organized around three planning steps:
|
|
50
76
|
|
|
51
77
|
### Queuing
|
|
52
|
-
|
|
78
|
+
|
|
79
|
+
Use `pyworkforce.queuing` when you need to estimate how many resources are required
|
|
80
|
+
to handle incoming work, for example calls arriving at a call center. The current
|
|
81
|
+
implementation uses Erlang C assumptions: constant arrival rate, infinite queue,
|
|
82
|
+
and no customer dropout.
|
|
53
83
|
|
|
54
84
|

|
|
55
85
|
|
|
56
|
-
- **queuing.ErlangC:**
|
|
57
|
-
|
|
86
|
+
- **queuing.ErlangC:** Calculate staffing requirements and performance metrics for one queue scenario.
|
|
87
|
+
- **queuing.MultiErlangC:** Run multiple Erlang C scenarios from a parameter grid.
|
|
58
88
|
|
|
59
89
|
### Scheduling
|
|
60
90
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- **scheduling.MinRequiredResources
|
|
66
|
-
weighted amount of scheduled resources (optionally weighted by shift cost), it ensures that in all intervals, there are
|
|
67
|
-
never fewer resources shifted than the ones required per period.
|
|
91
|
+
Use `pyworkforce.scheduling` when you already know the required resources by time
|
|
92
|
+
interval and need to choose how many people to place on each predefined shift.
|
|
93
|
+
|
|
94
|
+
- **scheduling.MinAbsDifference:** Minimizes the total absolute difference between required and scheduled resources.
|
|
95
|
+
- **scheduling.MinRequiredResources:** Minimizes the total weighted number of scheduled resources while ensuring every interval is covered.
|
|
68
96
|
|
|
69
97
|
### Rostering
|
|
70
98
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
99
|
+
Use `pyworkforce.rostering` when you have named resources and need to assign them
|
|
100
|
+
to days and shifts while respecting rules such as banned shifts, rest days,
|
|
101
|
+
minimum working hours, and preferences.
|
|
102
|
+
|
|
103
|
+
- **rostering.MinHoursRoster:** Builds a resource-level roster that covers shift requirements with the minimum scheduled hours.
|
|
74
104
|
|
|
75
105
|
### Queue systems:
|
|
76
106
|
|
|
@@ -95,7 +125,8 @@ Output:
|
|
|
95
125
|
'waiting_probability': 0.1741319335950498}
|
|
96
126
|
```
|
|
97
127
|
|
|
98
|
-
If you want to run
|
|
128
|
+
If you want to run several scenarios at the same time, use `MultiErlangC`.
|
|
129
|
+
For example, this tries different service-level targets:
|
|
99
130
|
|
|
100
131
|
```python
|
|
101
132
|
from pyworkforce.queuing import MultiErlangC
|
|
@@ -143,13 +174,13 @@ A brief introduction can be found in this [medium post](https://towardsdatascien
|
|
|
143
174
|
```python
|
|
144
175
|
from pyworkforce.scheduling import MinAbsDifference, MinRequiredResources
|
|
145
176
|
|
|
146
|
-
# Rows are
|
|
177
|
+
# Rows are days. Each value is the number of required positions for one hour of the day.
|
|
147
178
|
required_resources = [
|
|
148
179
|
[9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7],
|
|
149
180
|
[13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8]
|
|
150
181
|
]
|
|
151
182
|
|
|
152
|
-
# Each
|
|
183
|
+
# Each shift has 24 entries, one per hour. Use 1 if the shift covers that hour, otherwise 0.
|
|
153
184
|
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],
|
|
154
185
|
"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],
|
|
155
186
|
"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],
|
|
@@ -25,12 +25,11 @@ setup(
|
|
|
25
25
|
author_email="rodrigo.arenas456@gmail.com",
|
|
26
26
|
license="MIT",
|
|
27
27
|
classifiers=[
|
|
28
|
-
'License :: OSI Approved :: MIT License',
|
|
29
|
-
"Programming Language :: Python :: 3",
|
|
30
|
-
"Programming Language :: Python :: 3.
|
|
31
|
-
"Programming Language :: Python :: 3.
|
|
32
|
-
"Programming Language :: Python :: 3.
|
|
33
|
-
"Programming Language :: Python :: 3.11",
|
|
28
|
+
'License :: OSI Approved :: MIT License',
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
34
33
|
],
|
|
35
34
|
project_urls={
|
|
36
35
|
"Documentation": "https://pyworkforce.readthedocs.io/en/stable/",
|
|
@@ -39,11 +38,11 @@ setup(
|
|
|
39
38
|
},
|
|
40
39
|
packages=find_packages(include=['pyworkforce', 'pyworkforce.*']),
|
|
41
40
|
install_requires=[
|
|
42
|
-
'numpy>=1.
|
|
43
|
-
'ortools>=9.
|
|
44
|
-
'pandas>=
|
|
45
|
-
'joblib
|
|
46
|
-
],
|
|
47
|
-
python_requires=">=3.
|
|
41
|
+
'numpy>=1.26.0',
|
|
42
|
+
'ortools>=9.12.4544',
|
|
43
|
+
'pandas>=2.2.0',
|
|
44
|
+
'joblib>=1.4.0'
|
|
45
|
+
],
|
|
46
|
+
python_requires=">=3.12,<3.15",
|
|
48
47
|
include_package_data=True,
|
|
49
48
|
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|