Skip to content

Commit

Permalink
Merge pull request #9 from rodrigo-arenas/FEATURE/shifts-selection
Browse files Browse the repository at this point in the history
Feature/shifts selection
  • Loading branch information
rodrigo-arenas committed Mar 10, 2021
2 parents 5b8a66f + e006e5f commit ca5756f
Show file tree
Hide file tree
Showing 11 changed files with 334 additions and 23 deletions.
58 changes: 54 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# pyworkforce
Common tools for workforce management, schedule and optimization problems.
Common tools for workforce management, schedule and optimization problems built in top of tools like google's ortools
and custom modules

# Usage:
For complete list and details of examples go to the
[examples folder](https://github.com/rodrigo-arenas/pyworkforce/tree/develop/examples)

### Queue systems:

Expand All @@ -11,11 +15,11 @@ under some systems pre-defined parameters and goals.
#### Example:

```python
from pyworkforce.queuing.erlang import ErlangC
from pyworkforce.queuing import ErlangC

erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, max_occupancy=0.85, shrinkage=0.3)
erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, shrinkage=0.3)

positions_requirements = erlang.required_positions(service_level=0.8)
positions_requirements = erlang.required_positions(service_level=0.8, max_occupancy=0.85)
print("positions_requirements: ", positions_requirements)


Expand All @@ -25,3 +29,49 @@ print("positions_requirements: ", positions_requirements)
'occupancy': 0.7142857142857143,
'waiting_probability': 0.1741319335950498}
```

### Shifts Design

Find the optimal number of persons to assign to a pre-defined list of shifts, under a requirement of persons per period
of day and capacity restrictions

#### Example:

```python
from pyworkforce.shifts import MinAbsDifference

# Rows are the days, each entry of a row, is an hour of the day (24).
required_resources = [
[9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7],
[13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8]
]

# Each entry of a shift, is an hour of the day (24)
shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
"Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
"Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]}


scheduler = MinAbsDifference(num_days=2,
periods=24,
shifts_coverage=shifts_coverage,
required_resources=required_resources,
max_period_concurrency=25,
max_shift_concurrency=20)

solution = scheduler.solve()
print("solution :", solution)

>> solution: {'status': 'OPTIMAL',
'cost': 157.0,
'resources_shifts': [{'day': 0, 'shift': 'Morning', 'resources': 8},
{'day': 0, 'shift': 'Afternoon', 'resources': 11},
{'day': 0, 'shift': 'Night', 'resources': 9},
{'day': 0, 'shift': 'Mixed', 'resources': 1},
{'day': 1, 'shift': 'Morning', 'resources': 13},
{'day': 1, 'shift': 'Afternoon', 'resources': 17},
{'day': 1, 'shift': 'Night', 'resources': 13},
{'day': 1, 'shift': 'Mixed', 'resources': 0}]
}
```
10 changes: 7 additions & 3 deletions examples/queing/erlangc.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@
Shrinkage: 30%
"""

from pyworkforce.queuing.erlang import ErlangC
from pyworkforce.queuing import ErlangC

erlang = ErlangC(transactions=100, asa=20/60, aht=3, interval=30, max_occupancy=0.85, shrinkage=0.3)
erlang = ErlangC(transactions=100, aht=3, interval=30, asa=20/60, shrinkage=0.3)

positions_requirements = erlang.required_positions(service_level=0.8)
positions_requirements = erlang.required_positions(service_level=0.8, max_occupancy=0.85)
print("positions_requirements: ", positions_requirements)

achieved_service_level = erlang.service_level(positions=positions_requirements['raw_positions'])
print("achieved_service_level: ", achieved_service_level)

achieved_service_level = erlang.service_level(positions=positions_requirements['positions'],
scale_positions=True)
print("achieved_service_level: ", achieved_service_level)

waiting_probability = erlang.waiting_probability(positions=positions_requirements['raw_positions'])
print("waiting_probability: ", waiting_probability)

Expand Down
35 changes: 35 additions & 0 deletions examples/shifts/min_abs_difference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Requirement: Find the number of workers needed to schedule per shift in a production plant for the next 2 days with the
following conditions:
* There is a number of required persons per hour and day given in the matrix "required_resources"
* There are 4 available shifts called "Morning", "Afternoon", "Night", "Mixed"; their start and end hour is
determined in the dictionary "shifts_coverage", 1 meaning the shift is active at that hour, 0 otherwise
* The number of required workers per day and period (hour) is determined in the matrix "required_resources"
* The maximum number of workers that can be shifted simultaneously at any hour is 25, due plat capacity restrictions
* The maximum number of workers that can be shifted in a same shift, is 20
"""

from pyworkforce.shifts import MinAbsDifference

# Columns are an hour of the day, rows are the days
required_resources = [
[9, 11, 17, 9, 7, 12, 5, 11, 8, 9, 18, 17, 8, 12, 16, 8, 7, 12, 11, 10, 13, 19, 16, 7],
[13, 13, 12, 15, 18, 20, 13, 16, 17, 8, 13, 11, 6, 19, 11, 20, 19, 17, 10, 13, 14, 23, 16, 8]
]

# Each entry of a shift, is an hour of the day (24 columns)
shifts_coverage = {"Morning": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"Afternoon": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
"Night": [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
"Mixed": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]}


scheduler = MinAbsDifference(num_days=2,
periods=24,
shifts_coverage=shifts_coverage,
required_resources=required_resources,
max_period_concurrency=25,
max_shift_concurrency=20)

solution = scheduler.solve()
print(solution)
3 changes: 3 additions & 0 deletions pyworkforce/queuing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from pyworkforce.queuing.erlang import ErlangC

__all__ = ["ErlangC"]
54 changes: 42 additions & 12 deletions pyworkforce/queuing/erlang.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,41 @@


class ErlangC:
def __init__(self, transactions: float, asa: float, aht: float,
interval: int = None, shrinkage=0.0, max_occupancy=1.0,
def __init__(self, transactions: float, aht: float, asa: float,
interval: int = None, shrinkage=0.0,
**kwargs):
"""
Computes the number of positions required fo attend a number of transactions in a queue system based on ErlangC
Implementation based on: https://lucidmanager.org/data-science/call-centre-workforce-planning-erlang-c-in-r/
:param transactions: number of total transactions that comes in
:param transactions: number of total transactions that comes in an interval
:param aht: average handling time of a transaction (minutes)
:param asa: Required average speed of answer in minutes
:param interval: Interval length (minutes)
:param shrinkage: Percentage of time that an operator unit is not available
:param max_occupancy: Maximum percentage of time that an attending position can be occupied
"""

if transactions < 0:
raise ValueError("transactions can't be smaller than 0")

if aht < 0:
raise ValueError("aht can't be smaller than 0")

if asa < 0:
raise ValueError("asa can't be smaller than 0")

if interval < 0:
raise ValueError("interval can't be smaller than 0")

if shrinkage < 0 or shrinkage >= 1:
raise ValueError("shrinkage must be between in the interval [0,1)")

self.n_transactions = transactions
self.asa = asa
self.aht = aht
self.interval = interval
self.asa = asa
self.intensity = (self.n_transactions / self.interval) * self.aht
self.shrinkage = shrinkage
self.max_occupancy = max_occupancy

def waiting_probability(self, positions, scale_positions=False):
"""
Expand Down Expand Up @@ -67,13 +82,27 @@ def achieved_occupancy(self, positions, scale_positions=False):
else:
productive_positions = positions

return self.intensity/productive_positions
return self.intensity / productive_positions

def required_positions(self, service_level):
def required_positions(self, service_level: float, max_occupancy: float = 1.0):
"""
:param service_level: Target service level
:return: Number of positions needed to ensure the required service level
:param max_occupancy: Maximum fraction of time that an attending position can be occupied
:return:
* raw_positions: Required positions assuming shrinkage = 0
* positions: Number of positions needed to ensure the required service level
* service_level: Fraction of transactions that are expect to be assigned to a position,
before the asa time
* occupancy: Expected occupancy of positions
* waiting_probability: The probability of a transaction waits in queue
"""

if service_level < 0 or service_level > 1:
raise ValueError("service_level must be between 0 and 1")

if max_occupancy < 0 or service_level > 1:
raise ValueError("max_occupancy must be between 0 and 1")

positions = round(self.intensity + 1)
achieved_service_level = self.service_level(positions, scale_positions=False)
while achieved_service_level < service_level:
Expand All @@ -82,12 +111,13 @@ def required_positions(self, service_level):

achieved_occupancy = self.achieved_occupancy(positions, scale_positions=False)

if achieved_occupancy > self.max_occupancy:
raw_positions = ceil(self.intensity / self.max_occupancy)
raw_positions = ceil(positions)

if achieved_occupancy > max_occupancy:
raw_positions = ceil(self.intensity / max_occupancy)
achieved_occupancy = self.achieved_occupancy(raw_positions)
achieved_service_level = self.service_level(raw_positions)

raw_positions = ceil(positions)
waiting_probability = self.waiting_probability(positions=raw_positions)
positions = ceil(raw_positions / (1 - self.shrinkage))

Expand Down
3 changes: 3 additions & 0 deletions pyworkforce/shifts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from pyworkforce.shifts.shifts_selection import MinAbsDifference

__all__ = ["MinAbsDifference"]
126 changes: 126 additions & 0 deletions pyworkforce/shifts/shifts_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import numpy as np
from ortools.sat.python import cp_model
from pyworkforce.shifts.utils import check_positive_integer, check_positive_float


class MinAbsDifference:
def __init__(self, num_days: int,
periods: int,
shifts_coverage: dict,
required_resources: list,
max_period_concurrency: int,
max_shift_concurrency: int,
max_search_time: float = 120.0,
num_search_workers=4,
*args, **kwargs):
"""
Solves the following schedule problem:
Its required to find the optimal number of resources (agents, operators, doctors, etc) to allocate
in a shift, based on a pre-defined requirement of # of resources per period of the day (periods of hours,
half-hour, etc)
The optimal criteria, is defined as the amount of resources per shifts that minimize the total absolute
difference, between the required resources per period and the actual shifted by the solver
:param num_days: Number of days needed to schedule
:param periods: Number of working periods in a day
:param shifts_coverage: dict with structure {"shift_name": "shift_array"} where "shift_array" is an array
of size [periods] (p), 1 if shift covers period p, 0 otherwise
:param max_period_concurrency: Maximum resources allowed to shift in any period and day
:param required_resources: Array of size [days, periods]
:param max_shift_concurrency: Number of maximum allowed resources in a same shift
:param max_search_time: Maximum time in seconds to search for a solution
:param num_search_workers: Number of workers to search a solution
"""

is_valid_num_days = check_positive_integer("num_days", num_days)
is_valid_periods = check_positive_integer("periods", periods)
is_valid_max_period_concurrency = check_positive_integer("max_period_concurrency", max_period_concurrency)
is_valid_max_shift_concurrency = check_positive_integer("max_shift_concurrency", max_shift_concurrency)
is_valid_max_search_time = check_positive_float("max_search_time", max_search_time)
is_valid_num_search_workers = check_positive_integer("num_search_workers", num_search_workers)

self.num_days = num_days
self.shifts = list(shifts_coverage.keys())
self.num_shifts = len(self.shifts)
self.num_periods = periods
self.shifts_coverage_matrix = list(shifts_coverage.values())
self.max_shift_concurrency = max_shift_concurrency
self.max_period_concurrency = max_period_concurrency
self.required_resources = required_resources
self.max_search_time = max_search_time
self.num_search_workers = num_search_workers
self.transposed_shifts_coverage = None
self.status = None
self.solver = None

def solve(self):
sch_model = cp_model.CpModel()

# Resources: Number of resources assigned in day d to shift s
resources = np.empty(shape=(self.num_days, self.num_shifts), dtype='object')
# transition resources: Variable to change domain coordinates from min |x-a|
# to min t, s.t t>= x-a and t>= a-x
transition_resources = np.empty(shape=(self.num_days, self.num_periods), dtype='object')

# Resources
for d in range(self.num_days):
for s in range(self.num_shifts):
resources[d][s] = sch_model.NewIntVar(0, self.max_shift_concurrency, f'agents_d{d}s{s}')

for d in range(self.num_days):
for p in range(self.num_periods):
transition_resources[d][p] = sch_model.NewIntVar(-self.max_period_concurrency,
self.max_period_concurrency,
f'transition_resources_d{d}p{p}')

# Constrains

# transition must be between x-a and a-x
for d in range(self.num_days):
for p in range(self.num_periods):
sch_model.Add(transition_resources[d][p] >= (
sum(resources[d][s] * self.shifts_coverage_matrix[s][p] for s in range(self.num_shifts)) -
self.required_resources[d][p]))
sch_model.Add(transition_resources[d][p] >= (self.required_resources[d][p]
- sum(resources[d][s] * self.shifts_coverage_matrix[s][p]
for s in range(self.num_shifts))))

# Total programmed resources, must be less or equals to max_period_concurrency, for each day and period
for d in range(self.num_days):
for p in range(self.num_periods):
sch_model.Add(
sum(resources[d][s] * self.shifts_coverage_matrix[s][p] for s in range(self.num_shifts)) <=
self.max_period_concurrency)

# Objective Function: Minimize the absolute value of the difference between required and shifted resources

sch_model.Minimize(
sum(transition_resources[d][p] for d in range(self.num_days) for p in range(self.num_periods)))

self.solver = cp_model.CpSolver()
self.solver.parameters.max_time_in_seconds = self.max_search_time
self.solver.num_search_workers = self.num_search_workers

self.status = self.solver.Solve(sch_model)

if self.status in [cp_model.OPTIMAL, cp_model.FEASIBLE]:
resources_shifts = []
for d in range(self.num_days):
for s in range(self.num_shifts):
resources_shifts.append({
"day": d,
"shift": self.shifts[s],
"resources": self.solver.Value(resources[d][s])})

solution = {"status": self.solver.StatusName(self.status),
"cost": self.solver.ObjectiveValue(),
"resources_shifts": resources_shifts}
else:
solution = {"status": self.solver.StatusName(self.status),
"cost": -1,
"resources_shifts": [{'day': -1, 'shift': 'Unknown', 'resources': -1}]}

return solution
14 changes: 14 additions & 0 deletions pyworkforce/shifts/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@


def check_positive_integer(name, value):
if value <= 0 or not isinstance(value, int):
raise ValueError(f"{name} must be a positive integer")
else:
return True


def check_positive_float(name, value):
if value <= 0 or not isinstance(value, float):
raise ValueError(f"{name} must be a positive float")
else:
return True
Loading

0 comments on commit ca5756f

Please sign in to comment.