|
|
from datetime import date, datetime, time, timedelta |
|
|
from itertools import product |
|
|
from enum import Enum |
|
|
from random import Random |
|
|
from typing import Generator |
|
|
from dataclasses import dataclass, field |
|
|
|
|
|
from .domain import Employee, EmployeeSchedule, Shift |
|
|
|
|
|
|
|
|
class DemoData(Enum): |
|
|
SMALL = 'SMALL' |
|
|
LARGE = 'LARGE' |
|
|
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True) |
|
|
class CountDistribution: |
|
|
count: int |
|
|
weight: float |
|
|
|
|
|
|
|
|
def counts(distributions: tuple[CountDistribution, ...]) -> tuple[int, ...]: |
|
|
return tuple(distribution.count for distribution in distributions) |
|
|
|
|
|
|
|
|
def weights(distributions: tuple[CountDistribution, ...]) -> tuple[float, ...]: |
|
|
return tuple(distribution.weight for distribution in distributions) |
|
|
|
|
|
|
|
|
@dataclass(kw_only=True) |
|
|
class DemoDataParameters: |
|
|
locations: tuple[str, ...] |
|
|
required_skills: tuple[str, ...] |
|
|
optional_skills: tuple[str, ...] |
|
|
days_in_schedule: int |
|
|
employee_count: int |
|
|
optional_skill_distribution: tuple[CountDistribution, ...] |
|
|
shift_count_distribution: tuple[CountDistribution, ...] |
|
|
availability_count_distribution: tuple[CountDistribution, ...] |
|
|
random_seed: int = field(default=37) |
|
|
|
|
|
|
|
|
demo_data_to_parameters: dict[DemoData, DemoDataParameters] = { |
|
|
DemoData.SMALL: DemoDataParameters( |
|
|
locations=("Ambulatory care", "Critical care", "Pediatric care"), |
|
|
required_skills=("Doctor", "Nurse"), |
|
|
optional_skills=("Anaesthetics", "Cardiology"), |
|
|
days_in_schedule=14, |
|
|
employee_count=15, |
|
|
optional_skill_distribution=( |
|
|
CountDistribution(count=1, weight=3), |
|
|
CountDistribution(count=2, weight=1) |
|
|
), |
|
|
shift_count_distribution=( |
|
|
CountDistribution(count=1, weight=0.9), |
|
|
CountDistribution(count=2, weight=0.1) |
|
|
), |
|
|
availability_count_distribution=( |
|
|
CountDistribution(count=1, weight=4), |
|
|
CountDistribution(count=2, weight=3), |
|
|
CountDistribution(count=3, weight=2), |
|
|
CountDistribution(count=4, weight=1) |
|
|
), |
|
|
random_seed=37 |
|
|
), |
|
|
|
|
|
DemoData.LARGE: DemoDataParameters( |
|
|
locations=("Ambulatory care", |
|
|
"Neurology", |
|
|
"Critical care", |
|
|
"Pediatric care", |
|
|
"Surgery", |
|
|
"Radiology", |
|
|
"Outpatient"), |
|
|
required_skills=("Doctor", "Nurse"), |
|
|
optional_skills=("Anaesthetics", "Cardiology", "Radiology"), |
|
|
days_in_schedule=28, |
|
|
employee_count=50, |
|
|
optional_skill_distribution=( |
|
|
CountDistribution(count=1, weight=3), |
|
|
CountDistribution(count=2, weight=1) |
|
|
), |
|
|
shift_count_distribution=( |
|
|
CountDistribution(count=1, weight=0.5), |
|
|
CountDistribution(count=2, weight=0.3), |
|
|
CountDistribution(count=3, weight=0.2) |
|
|
), |
|
|
availability_count_distribution=( |
|
|
CountDistribution(count=5, weight=4), |
|
|
CountDistribution(count=10, weight=3), |
|
|
CountDistribution(count=15, weight=2), |
|
|
CountDistribution(count=20, weight=1) |
|
|
), |
|
|
random_seed=37 |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay") |
|
|
LAST_NAMES = ("Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt") |
|
|
SHIFT_LENGTH = timedelta(hours=8) |
|
|
MORNING_SHIFT_START_TIME = time(hour=6, minute=0) |
|
|
DAY_SHIFT_START_TIME = time(hour=9, minute=0) |
|
|
AFTERNOON_SHIFT_START_TIME = time(hour=14, minute=0) |
|
|
NIGHT_SHIFT_START_TIME = time(hour=22, minute=0) |
|
|
|
|
|
SHIFT_START_TIMES_COMBOS = ( |
|
|
(MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME), |
|
|
(MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME), |
|
|
(MORNING_SHIFT_START_TIME, DAY_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME), |
|
|
) |
|
|
|
|
|
|
|
|
location_to_shift_start_time_list_map = dict() |
|
|
|
|
|
|
|
|
def earliest_monday_on_or_after(target_date: date): |
|
|
""" |
|
|
Returns the date of the next given weekday after |
|
|
the given date. For example, the date of next Monday. |
|
|
|
|
|
NB: if it IS the day we're looking for, this returns 0. |
|
|
consider then doing onDay(foo, day + 1). |
|
|
""" |
|
|
days = (7 - target_date.weekday()) % 7 |
|
|
return target_date + timedelta(days=days) |
|
|
|
|
|
|
|
|
def generate_demo_data(demo_data_or_parameters: DemoData | DemoDataParameters) -> EmployeeSchedule: |
|
|
global location_to_shift_start_time_list_map, demo_data_to_parameters |
|
|
if isinstance(demo_data_or_parameters, DemoData): |
|
|
parameters = demo_data_to_parameters[demo_data_or_parameters] |
|
|
else: |
|
|
parameters = demo_data_or_parameters |
|
|
|
|
|
start_date = earliest_monday_on_or_after(date.today()) |
|
|
random = Random(parameters.random_seed) |
|
|
shift_template_index = 0 |
|
|
for location in parameters.locations: |
|
|
location_to_shift_start_time_list_map[location] = SHIFT_START_TIMES_COMBOS[shift_template_index] |
|
|
shift_template_index = (shift_template_index + 1) % len(SHIFT_START_TIMES_COMBOS) |
|
|
|
|
|
name_permutations = [f'{first_name} {last_name}' |
|
|
for first_name, last_name in product(FIRST_NAMES, LAST_NAMES)] |
|
|
random.shuffle(name_permutations) |
|
|
|
|
|
employees = [] |
|
|
for i in range(parameters.employee_count): |
|
|
count, = random.choices(population=counts(parameters.optional_skill_distribution), |
|
|
weights=weights(parameters.optional_skill_distribution)) |
|
|
skills = [] |
|
|
skills += random.sample(parameters.optional_skills, count) |
|
|
skills += random.sample(parameters.required_skills, 1) |
|
|
employees.append( |
|
|
Employee(name=name_permutations[i], |
|
|
skills=set(skills)) |
|
|
) |
|
|
|
|
|
shifts: list[Shift] = [] |
|
|
|
|
|
def id_generator(): |
|
|
current_id = 0 |
|
|
while True: |
|
|
yield str(current_id) |
|
|
current_id += 1 |
|
|
|
|
|
ids = id_generator() |
|
|
|
|
|
for i in range(parameters.days_in_schedule): |
|
|
count, = random.choices(population=counts(parameters.availability_count_distribution), |
|
|
weights=weights(parameters.availability_count_distribution)) |
|
|
employees_with_availabilities_on_day = random.sample(employees, count) |
|
|
current_date = start_date + timedelta(days=i) |
|
|
for employee in employees_with_availabilities_on_day: |
|
|
rand_num = random.randint(0, 2) |
|
|
if rand_num == 0: |
|
|
employee.unavailable_dates.add(current_date) |
|
|
elif rand_num == 1: |
|
|
employee.undesired_dates.add(current_date) |
|
|
elif rand_num == 2: |
|
|
employee.desired_dates.add(current_date) |
|
|
shifts += generate_shifts_for_day(parameters, current_date, random, ids) |
|
|
|
|
|
shift_count = 0 |
|
|
for shift in shifts: |
|
|
shift.id = str(shift_count) |
|
|
shift_count += 1 |
|
|
|
|
|
return EmployeeSchedule( |
|
|
employees=employees, |
|
|
shifts=shifts |
|
|
) |
|
|
|
|
|
|
|
|
def generate_shifts_for_day(parameters: DemoDataParameters, current_date: date, random: Random, |
|
|
ids: Generator[str, any, any]) -> list[Shift]: |
|
|
global location_to_shift_start_time_list_map |
|
|
shifts = [] |
|
|
for location in parameters.locations: |
|
|
shift_start_times = location_to_shift_start_time_list_map[location] |
|
|
for start_time in shift_start_times: |
|
|
shift_start_date_time = datetime.combine(current_date, start_time) |
|
|
shift_end_date_time = shift_start_date_time + SHIFT_LENGTH |
|
|
shifts += generate_shifts_for_timeslot(parameters, shift_start_date_time, shift_end_date_time, |
|
|
location, random, ids) |
|
|
|
|
|
return shifts |
|
|
|
|
|
|
|
|
def generate_shifts_for_timeslot(parameters: DemoDataParameters, timeslot_start: datetime, timeslot_end: datetime, |
|
|
location: str, random: Random, ids: Generator[str, any, any]) -> list[Shift]: |
|
|
shift_count, = random.choices(population=counts(parameters.shift_count_distribution), |
|
|
weights=weights(parameters.shift_count_distribution)) |
|
|
|
|
|
shifts = [] |
|
|
for i in range(shift_count): |
|
|
if random.random() >= 0.5: |
|
|
required_skill = random.choice(parameters.required_skills) |
|
|
else: |
|
|
required_skill = random.choice(parameters.optional_skills) |
|
|
shifts.append(Shift( |
|
|
id=next(ids), |
|
|
start=timeslot_start, |
|
|
end=timeslot_end, |
|
|
location=location, |
|
|
required_skill=required_skill)) |
|
|
|
|
|
return shifts |
|
|
|