From 88ebe918602c4869fad40b6ecd2ea67b08ee4489 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Thu, 4 Jul 2024 14:26:53 -0700 Subject: [PATCH 01/49] add nl formulation function stubs --- employee_scheduling.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 47691ff..27e8f52 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -19,7 +19,11 @@ ConstrainedQuadraticModel, quicksum, ) -from dwave.system import LeapHybridCQMSampler +from dwave.optimization.model import ( + Model, + BinaryVariable +) +from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler from utils import DAYS, SHIFTS @@ -218,3 +222,21 @@ def run_cqm(cqm): return sampleset, errors return feasible_sampleset, None + + +def build_nl( + availability, + shifts, + min_shifts, + max_shifts, + shift_min, + shift_max, + requires_manager, + allow_isolated_days_off, + max_consecutive_shifts, +): + ... + + +def run_nl(nl): + ... \ No newline at end of file From fae8bb148ee7376c631076af3f8eb4c33208e211 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Thu, 4 Jul 2024 17:31:17 -0700 Subject: [PATCH 02/49] move multiprocess import to top of file --- app.py | 49 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 9ec5e4a..754b120 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +import multiprocess from typing import TYPE_CHECKING, Any import diskcache @@ -21,8 +22,14 @@ import employee_scheduling import utils -from app_configs import (APP_TITLE, DEBUG, LARGE_SCENARIO, MEDIUM_SCENARIO, MIN_MAX_EMPLOYEES, - SMALL_SCENARIO) +from app_configs import ( + APP_TITLE, + DEBUG, + LARGE_SCENARIO, + MEDIUM_SCENARIO, + MIN_MAX_EMPLOYEES, + SMALL_SCENARIO, +) from app_html import errors_list, set_html if TYPE_CHECKING: @@ -33,9 +40,8 @@ # Fix for Dash background callbacks crashing on macOS 10.13+ (https://bugs.python.org/issue33725) # See https://github.com/dwave-examples/flow-shop-scheduling/pull/4 for more details. -import multiprocess if multiprocess.get_start_method(allow_none=True) is None: - multiprocess.set_start_method('spawn') + multiprocess.set_start_method("spawn") app = Dash( __name__, @@ -47,7 +53,9 @@ @app.callback( - Output({"type": "to-collapse-class", "index": MATCH}, "className", allow_duplicate=True), + Output( + {"type": "to-collapse-class", "index": MATCH}, "className", allow_duplicate=True + ), inputs=[ Input({"type": "collapse-trigger", "index": MATCH}, "n_clicks"), State({"type": "to-collapse-class", "index": MATCH}, "className"), @@ -114,7 +122,10 @@ def set_scenario( shifts_per_employees, employees_per_shift, random_seed, - False, False, False, False + False, + False, + False, + False, ) @@ -129,7 +140,9 @@ def set_scenario( State("employees-per-shift-select", "tooltip"), ], ) -def update_employees_per_shift(value: int, tooltip: dict[str, Any]) -> tuple[int, dict, dict]: +def update_employees_per_shift( + value: int, tooltip: dict[str, Any] +) -> tuple[int, dict, dict]: """Update the employees-per-shift slider max if num-employees is changed.""" marks = { MIN_MAX_EMPLOYEES["min"]: str(MIN_MAX_EMPLOYEES["min"]), @@ -222,10 +235,7 @@ def custom_random_seed(value: int, scenario: int) -> int: Output("schedule-tab", "disabled", allow_duplicate=True), Output("tabs", "value"), Output({"type": "to-collapse-class", "index": 1}, "style", allow_duplicate=True), - inputs=[ - Input("num-employees-select", "value"), - Input("seed-select", "value") - ], + inputs=[Input("num-employees-select", "value"), Input("seed-select", "value")], ) def disp_initial_sched( num_employees: int, rand_seed: int @@ -269,10 +279,7 @@ def update_error_sidebar(run_click: int, prev_classes) -> tuple[dict, str]: if "collapsed" in classes: return no_update, no_update - return ( - {"display": "none"}, - prev_classes + " collapsed" - ) + return ({"display": "none"}, prev_classes + " collapsed") @app.callback( @@ -291,8 +298,16 @@ def update_error_sidebar(run_click: int, prev_classes) -> tuple[dict, str]: ], running=[ # show cancel button and hide run button, and disable and animate results tab - (Output("cancel-button", "style"), {"display": "inline-block"}, {"display": "none"}), - (Output("run-button", "style"), {"display": "none"}, {"display": "inline-block"}), + ( + Output("cancel-button", "style"), + {"display": "inline-block"}, + {"display": "none"}, + ), + ( + Output("run-button", "style"), + {"display": "none"}, + {"display": "inline-block"}, + ), # switch to schedule tab while running (Output("schedule-tab", "disabled"), False, False), (Output("tabs", "value"), "schedule-tab", "schedule-tab"), From f20dbf34ef52f59fa9f5abf07b9f7cec296b5640 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 6 Sep 2024 13:10:01 -0700 Subject: [PATCH 03/49] Functional nl formulation --- nl_formulation.py | 389 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 nl_formulation.py diff --git a/nl_formulation.py b/nl_formulation.py new file mode 100644 index 0000000..094739b --- /dev/null +++ b/nl_formulation.py @@ -0,0 +1,389 @@ +from typing import Callable + +import numpy as np +from dwave.optimization.mathematical import add +from dwave.optimization.model import Model +from dwave.optimization.symbols import BinaryVariable +from dwave.system import LeapHybridNLSampler +from tabulate import tabulate +from colorama import Fore, Style + +availability = { + "Marcus K-Mgr": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], + "Robert C-Mgr": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2], + "Jonathan B": [1, 1, 0, 0, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2], + "Thomas U": [1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 2, 0, 1, 1], + "Herbert I": [1, 2, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1], + "Donna Z": [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1], + "Karen T": [1, 1, 1, 1, 2, 1, 0, 1, 1, 1, 1, 2, 1, 1], + "Seth F": [1, 2, 1, 1, 0, 2, 1, 1, 1, 1, 1, 1, 1, 1], + "Stephanie F": [1, 1, 2, 2, 1, 1, 2, 1, 0, 1, 1, 1, 1, 1], + "Casey B": [1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 2], + "Mike P": [1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1], + "Mike P-Tr": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], +} + +shifts = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14"] + +min_shifts = 5 +max_shifts = 7 +shift_min = 3 +shift_max = 6 +requires_manager = True +allow_isolated_days_off = False +max_consecutive_shifts = 6 + + +def build_nl( + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, +) -> tuple[Model, BinaryVariable]: + # Create list of employees + employees = list(availability.keys()) + model = Model() + + # Create a binary symbol representing the assignment of employees to shifts + # i.e. assignments[employee][shift] = 1 if assigned, else 0 + num_employees = len(employees) + num_shifts = len(shifts) + assignments = model.binary((num_employees, num_shifts)) + + # Create availability constant + availability_list = [availability[employee] for employee in employees] + availability_const = model.constant(availability_list) + + # Initialize model constants + min_shifts_constant = model.constant(min_shifts) + max_shifts_constant = model.constant(max_shifts) + shift_min_constant = model.constant(shift_min) + shift_max_constant = model.constant(shift_max) + max_consecutive_shifts_c = model.constant(max_consecutive_shifts) + + # OBJECTIVES: + # Objective: give employees preferred schedules (val = 2) + obj = (assignments * availability_const).sum() + + # Objective: for infeasible solutions, focus on right number of shifts for employees + target_shifts = model.constant((min_shifts + max_shifts) / 2) + shift_difference_list = [ + (assignments[e, :].sum() - target_shifts) ** 2 for e in range(num_employees) + ] + obj += add(*shift_difference_list) + model.minimize(-obj) + + # CONSTRAINTS: + # Only schedule employees when they're available + model.add_constraint((availability_const >= assignments).all()) + + for e in range(len(employees)): + # Schedule employees for at most max_shifts + model.add_constraint(assignments[e, :].sum() <= max_shifts_constant) + + # Schedule employees for at least min_shifts + model.add_constraint(assignments[e, :].sum() >= min_shifts_constant) + + # Every shift needs shift_min and shift_max employees working + for s in range(num_shifts): + model.add_constraint(assignments[:, s].sum() <= shift_max_constant) + model.add_constraint(assignments[:, s].sum() >= shift_min_constant) + + managers_c = model.constant( + [employees.index(e) for e in employees if e[-3:] == "Mgr"] + ) + trainees_c = model.constant( + [employees.index(e) for e in employees if e[-2:] == "Tr"] + ) + + if not allow_isolated_days_off: + negthree_c = model.constant(-3) + zero_c = model.constant(0) + for e in range(len(employees)): + for s1 in range(len(shifts) - 2): + s2, s3 = s1 + 1, s1 + 2 + model.add_constraint( + negthree_c * assignments[e, s2] + + assignments[e][s1] * assignments[e][s2] + + assignments[e][s1] * assignments[e][s3] + + assignments[e][s2] * assignments[e][s3] + <= zero_c + ) + + if requires_manager: + one_c = model.constant(1) + for shift in range(len(shifts)): + model.add_constraint(assignments[managers_c][:, shift].sum() >= one_c) + + # Don't exceed max_consecutive_shifts + for e in range(num_employees): + for s in range(num_shifts - max_consecutive_shifts + 1): + s_window = s + max_consecutive_shifts + 1 + model.add_constraint( + assignments[e][s : s_window + 1].sum() <= max_consecutive_shifts_c + ) + + # Trainee must work on shifts with trainer + trainers = [] + for i in trainees_c.state(): + trainer_name = employees[int(i)][:-3] + trainers.append(employees.index(trainer_name)) + trainers_c = model.constant(trainers) + + model.add_constraint((assignments[trainees_c] <= assignments[trainers_c]).all()) + + return model, assignments + + +def run_nl( + model: Model, + assignments: BinaryVariable, + sampler: LeapHybridNLSampler, + time_limit: int = 15, + solution_printer: Callable | None = None, +) -> None: + if not model.is_locked(): + model.lock() + + sampler = LeapHybridNLSampler() + sampler.sample(model, time_limit=time_limit) + if solution_printer is not None: + solution_printer(assignments) + else: + print(model.state_size()) + + +def pretty_print_solution(assignments: BinaryVariable): + result = assignments.state() + employees = list(availability.keys()) + shift_availability = list(availability.values()) + unicode_check = "*" + unicode_heart = Fore.LIGHTGREEN_EX + "@" + Style.RESET_ALL + unicode_heartbreak = Fore.LIGHTRED_EX + "/" + Style.RESET_ALL + unicode_sos = Fore.RED + "E" + Style.RESET_ALL + header = ["Employee", *shifts] + solution_table = [] + + print(f"{unicode_check} = shift assigned") + print(f"{unicode_heart} = preferred shift assigned") + print(f"{unicode_heartbreak} = preferred shift not assigned") + print(f"{unicode_sos} = unavailable shift assigned") + for i, employee in enumerate(employees): + employee_shifts = [employee] + solution_table.append(employee_shifts) + for j in range(len(shifts)): + match result[i, j], shift_availability[i][j]: + case 0, 0 | 1: + employee_shifts.append("") + case 0, 2: + employee_shifts.append(unicode_heartbreak) + case 1, 1: + employee_shifts.append(unicode_check) + case 1, 2: + employee_shifts.append(unicode_heart) + case 1, 0: + employee_shifts.append(unicode_sos) + case _: + raise Exception( + f"Case result={result[i,j]}, available={shift_availability[i][j]} not a valid case" + ) + + print(tabulate(solution_table, headers=header, tablefmt="grid")) + + +def validate_schedule( + assignments: BinaryVariable, + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, +) -> list[str]: + result = assignments.state() + employees = list(availability.keys()) + errors = [] + errors.extend( + [ + *validate_availability(result, availability, employees, shifts), + *validate_shifts_per_employee(result, employees, min_shifts, max_shifts), + *validate_employees_per_shift(result, shifts, shift_min, shift_max), + *validate_max_consecutive_shifts(result, employees, max_consecutive_shifts), + *validate_trainee_shifts(result, employees), + ] + ) + if requires_manager: + errors.extend(validate_requires_manager(result, employees)) + if not allow_isolated_days_off: + errors.extend(validate_isolated_days_off(result, employees, shifts)) + return errors + + +def validate_availability( + results: np.ndarray, + availability: dict[str, list[int]], + employees: list[str], + shifts: list[str], +) -> list[str]: + errors = [] + for e, employee in enumerate(employees): + for s, shift in enumerate(shifts): + if results[e, s] > availability[employee][s]: + msg = ( + f"Employee {employee} scheduled for shift {shift} but not available" + ) + errors.append(msg) + return errors + + +def validate_shifts_per_employee( + results: np.ndarray, employees: list[str], min_shifts: int, max_shifts: int +) -> list[str]: + errors = [] + for e, employee in enumerate(employees): + num_shifts = results[e, :].sum() + msg = None + if num_shifts < min_shifts: + msg = f"Employee {employee} scheduled for {num_shifts} but requires at least {min_shifts}" + elif num_shifts > max_shifts: + msg = f"Employee {employee} scheduled for {num_shifts} but requires at most {max_shifts}" + if msg: + errors.append(msg) + return errors + + +def validate_employees_per_shift( + results: np.ndarray, shifts: list[str], shift_min: int, shift_max: int +) -> list[str]: + errors = [] + for s, shift in enumerate(shifts): + num_employees = results[:, s].sum() + msg = None + if num_employees < shift_min: + msg = f"Shift {shift} scheduled for {num_employees} employees but requires at least {shift_min}" + elif num_employees > shift_max: + msg = f"Shift {shift} scheduled for {num_employees} employees but requires at most {shift_max}" + if msg: + errors.append(msg) + return errors + + +def validate_requires_manager( + results: np.ndarray, + employees: list[str], +) -> list[str]: + errors = [] + employee_arr = np.asarray( + [employees.index(e) for e in employees if e[-3:] == "Mgr"] + ) + managers_per_shift = results[employee_arr].sum(axis=0) + for shift, num_managers in enumerate(managers_per_shift): + if num_managers == 0: + msg = f"Shift {shift + 1} requires at least 1 manager but 0 scheduled" + errors.append(msg) + + return errors + + +def validate_isolated_days_off( + results: np.ndarray, + employees: list[str], + shifts: list[str], +) -> list[str]: + errors = [] + isolated_pattern = np.array([1, 0, 1]) + for e, employee in enumerate(employees): + shift_triples = [results[e, i : i + 3] for i in range(results.shape[1] - 2)] + for s, shift_set in enumerate(shift_triples): + if np.equal(shift_set, isolated_pattern).all(): + msg = f"Employee {employee} has an isolated day off on shift {shifts[s+1]}" + errors.append(msg) + + return errors + + +def validate_max_consecutive_shifts( + results: np.ndarray, employees: list[str], max_consecutive_shifts: int +) -> list[str]: + errors = [] + for e, employee in enumerate(employees): + for shifts in [ + results[e, i : i + max_consecutive_shifts] + for i in range(results.shape[1] - max_consecutive_shifts) + ]: + if shifts.sum() > max_consecutive_shifts: + msg = f"Employee {employee} scheduled for more than {max_consecutive_shifts} max consecutive shifts" + errors.append(msg) + break + + return errors + + +def validate_trainee_shifts(results: np.ndarray, employees: list[str]) -> list[str]: + errors = [] + trainees = {employees.index(e): e for e in employees if e[-2:] == "Tr"} + trainers = { + employees.index(e): e for e in employees if e + "-Tr" in trainees.values() + } + for (trainee_i, trainee), (trainer_i, trainer) in zip( + trainees.items(), trainers.items() + ): + same_shifts = np.less_equal(results[trainee_i], results[trainer_i]) + for i, s in enumerate(same_shifts): + if not s: + msg = f"Trainee {trainee} scheduled on shift {i+1} without trainer {trainer}" + errors.append(msg) + + return errors + + +if __name__ == "__main__": + model, assignments = build_nl( + availability, + shifts, + min_shifts, + max_shifts, + shift_min, + shift_max, + requires_manager, + allow_isolated_days_off, + max_consecutive_shifts, + ) + + # model.states.resize(1) + # state = np.ones(12 * 14).reshape(12, 14) + # state[0, 2] = 0 + # assignments.set_state(0, state) + + model.lock() + + sampler: LeapHybridNLSampler = LeapHybridNLSampler() + sampler.sample(model) + + print(model.objective.state()) + + pretty_print_solution(assignments) + + errors = validate_schedule( + assignments, + availability, + shifts, + min_shifts, + max_shifts, + shift_min, + shift_max, + requires_manager, + allow_isolated_days_off, + max_consecutive_shifts, + ) + + for e in errors: + print(e) From c7a7a6645c2450f26eb61392e999603e7e16adc1 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 11 Sep 2024 10:55:52 -0700 Subject: [PATCH 04/49] Move sampling to __main__ --- nl_formulation.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/nl_formulation.py b/nl_formulation.py index 094739b..31e1d80 100644 --- a/nl_formulation.py +++ b/nl_formulation.py @@ -57,6 +57,8 @@ def build_nl( # Create availability constant availability_list = [availability[employee] for employee in employees] + for i, sublist in enumerate(availability_list): + availability_list[i] = [a if a != 2 else 100 for a in sublist] availability_const = model.constant(availability_list) # Initialize model constants @@ -65,6 +67,7 @@ def build_nl( shift_min_constant = model.constant(shift_min) shift_max_constant = model.constant(shift_max) max_consecutive_shifts_c = model.constant(max_consecutive_shifts) + one_c = model.constant(1) # OBJECTIVES: # Objective: give employees preferred schedules (val = 2) @@ -76,6 +79,7 @@ def build_nl( (assignments[e, :].sum() - target_shifts) ** 2 for e in range(num_employees) ] obj += add(*shift_difference_list) + model.minimize(-obj) # CONSTRAINTS: @@ -104,6 +108,7 @@ def build_nl( if not allow_isolated_days_off: negthree_c = model.constant(-3) zero_c = model.constant(0) + # Adding many small constraints greatly improves feasibility for e in range(len(employees)): for s1 in range(len(shifts) - 2): s2, s3 = s1 + 1, s1 + 2 @@ -116,7 +121,6 @@ def build_nl( ) if requires_manager: - one_c = model.constant(1) for shift in range(len(shifts)): model.add_constraint(assignments[managers_c][:, shift].sum() >= one_c) @@ -358,13 +362,10 @@ def validate_trainee_shifts(results: np.ndarray, employees: list[str]) -> list[s max_consecutive_shifts, ) - # model.states.resize(1) - # state = np.ones(12 * 14).reshape(12, 14) - # state[0, 2] = 0 - # assignments.set_state(0, state) - model.lock() + time_limit = max(len(availability), len(shifts)) + sampler: LeapHybridNLSampler = LeapHybridNLSampler() sampler.sample(model) From dbaa8664a47904dd7bbce619e2eae12f852216a9 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 14:11:22 -0700 Subject: [PATCH 05/49] add build_nl() function --- employee_scheduling.py | 91 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 27e8f52..ce88a49 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -20,10 +20,11 @@ quicksum, ) from dwave.optimization.model import ( - Model, - BinaryVariable + Model ) +from dwave.optimization.mathematical import add from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler +import numpy as np from utils import DAYS, SHIFTS @@ -225,18 +226,82 @@ def run_cqm(cqm): def build_nl( - availability, - shifts, - min_shifts, - max_shifts, - shift_min, - shift_max, - requires_manager, - allow_isolated_days_off, - max_consecutive_shifts, -): + availability: dict[str, list[int]], + shifts: dict[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, +) -> Model: + # Create list of employees + employees = list(availability.keys()) + model = Model() + + # Create a binary symbol representing the assignment of employees to shifts + # i.e. assignments[employee][shift] = 1 if assigned, else 0 + num_employees = len(employees) + num_shifts = len(shifts) + assignments = model.binary((num_employees, num_shifts)) + + # Create availability constant + availability_list = [availability[employee] for employee in employees] + availability_const = model.constant(availability_list) + + # Initialize model constants + min_shifts_constant = model.constant(min_shifts) + max_shifts_constant = model.constant(max_shifts) + shift_min_constant = model.constant(shift_min) + shift_max_constant = model.constant(shift_max) + + # OBJECTIVES: + # Objective: give employees preferred schedules (val = 2) + obj = (assignments * availability_const).sum() + + # Objective: for infeasible solutions, focus on right number of shifts for employees + target_shifts = model.constant((min_shifts + max_shifts) / 2) + shift_difference_list = [(assignments[e,:].sum() - target_shifts) ** 2 + for e in num_employees] + obj += add(*shift_difference_list) + + + # CONSTRAINTS: + # Only schedule employees when they're available + model.add_constraint(assignments >= availability_const) + + for e, employee in enumerate(employees): + # Schedule employees for at most max_shifts + model.add_constraint(assignments[e,:].sum() <= max_shifts_constant) + + # Schedule employees for at least min_shifts + model.add_constraint(assignments[e,:].sum() >= min_shifts_constant) + + # Every shift needs shift_min and shift_max employees working + for s in range(num_shifts): + model.add_constraint(assignments[:,s].sum() <= shift_max_constant) + model.add_constraint(assignments[:,s].sum() >= shift_min_constant) + + # Days off must be consecutive + if not allow_isolated_days_off: + # middle range shifts - pattern 101 penalized + ... + + # Require a manager on every shift + if requires_manager: + ... + + # Don't exceed max_consecutive_shifts + + # Trainee must work on shifts with trainer + + return model + + +def run_nl(nl: Model) -> tuple[Model, defaultdict[str, list[str]]]: ... -def run_nl(nl): +if __name__ == '__main__': ... \ No newline at end of file From 1307b74c48495d8a66d1e495931cea5db4c30036 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 14:12:37 -0700 Subject: [PATCH 06/49] require `dwave-optimization 0.3.0` for scalar broadcasting --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4144089..7c7987d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ dash[diskcache]==2.16.1 dash-bootstrap-components==1.6.0 -dwave-ocean-sdk>=6.7 +dwave-ocean-sdk>=7.0.0 Faker==21.0.0 pandas>=2.0 +dwave-optimization>=0.3.0 \ No newline at end of file From 73e8fdc94d2b31d02e32dfbfd6644ecbf220113a Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 14:56:29 -0700 Subject: [PATCH 07/49] add nl model validation function to utils.py --- utils.py | 252 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/utils.py b/utils.py index 09b8da7..215628e 100644 --- a/utils.py +++ b/utils.py @@ -15,12 +15,14 @@ import datetime import random import string +from collections import defaultdict from app_configs import REQUESTED_SHIFT_ICON, UNAVAILABLE_ICON import numpy as np import pandas as pd from dash import dash_table from faker import Faker +from dwave.optimization.symbols import BinaryVariable NOW = datetime.datetime.now() SCHEDULE_LENGTH = 14 @@ -253,3 +255,253 @@ def availability_to_dict(availability_list): ] return availability_dict + + +def validate_nl_schedule( + assignments: BinaryVariable, + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Detect any errors in a solved NL scheduling model. + + Since NL models do not currently support constraint labels, this function + is required to detect any constraint violations and format them for display + in the user interface. + + Args: + assignments (BinaryVariable): Assignments generated from sampling the + NL model. + availability (dict[str, list[int]]): Employee availability used as input + when building the NL model. + shifts (list[str]): Shift labels used as input when building the NL + model. + min_shifts (int): Min shifts to be scheduled per employee. + max_shifts (int): Max shifts to be scheduled per employee. + shift_min (int): Min employees to be scheduled per shift. + shift_max (int): Max employees to be scheduled per shift. + requires_manager (bool): Whether a manager is required on every shift. + allow_isolated_days_off (bool): Whether to allow isolated unscheduled + shifts (pattern on-off-on). + max_consecutive_shifts (int): Maximum amount of shifts in a row an + employee can be scheduled. + msgs (dict[str, tuple[str, str]]): Error message template dictionary. + Must be formatted like: + ``` + msgs = { + 'error_type': ('Error message', 'message template with {replacement fields}'), + ... + } + ``` + + Raises: + ValueError: Raised if the `msgs` dictionary doesn't contain the required keys. + + Returns: + errors (defaultdict[str, list[str]]): Error descriptions and messages. + """ + # Required keys to match existing error messages in employee_scheduling.py + required_msg_keys = [ + "unavailable", + "overtime", + "insufficient", + "understaffed", + "overstaffed", + "isolated", + "manager_issue", + "too_many_consecutive", + "trainee_issue", + ] + for key in required_msg_keys: + if key not in msgs: + raise ValueError(f"`msgs` dictionary missing required key `{key}`") + + # Pull solution state as ndarray + result = assignments.state() + employees = list(availability.keys()) + + errors = defaultdict(list) + + _validate_availability(result, availability, employees, shifts, errors, msgs) + _validate_shifts_per_employee( + result, employees, min_shifts, max_shifts, errors, msgs + ) + _validate_employees_per_shift(result, shifts, shift_min, shift_max, errors, msgs) + _validate_max_consecutive_shifts( + result, employees, shifts, max_consecutive_shifts, errors, msgs + ) + _validate_trainee_shifts(result, employees, shifts, errors, msgs) + if requires_manager: + _validate_requires_manager(result, employees, shifts, errors, msgs) + if not allow_isolated_days_off: + _validate_isolated_days_off(result, employees, shifts, errors, msgs) + + return errors + + +def _validate_availability( + results: np.ndarray, + availability: dict[str, list[int]], + employees: list[str], + shifts: list[str], + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates employee availability for the solution and updates the `errors` + dictionary with any errors found. Requires the `msgs` dict to have the + key `'unavailable'`.""" + msg_key, msg_template = msgs["unavailable"] + for e, employee in enumerate(employees): + for s, shift in enumerate(shifts): + if results[e, s] > availability[employee][s]: + errors[msg_key].append( + msg_template.format(employee=employee, day=shift) + ) + return errors + + +def _validate_shifts_per_employee( + results: np.ndarray, + employees: list[str], + min_shifts: int, + max_shifts: int, + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates the number of shifts per employee for the solution and updates + the `errors` dictionary with any errors found. Requires the `msgs` dict + to have the keys `'insufficient'` and `'overtime'`.""" + insufficient_key, insufficient_template = msgs["insufficient"] + overtime_key, overtime_template = msgs["overtime"] + for e, employee in enumerate(employees): + num_shifts = results[e, :].sum() + if num_shifts < min_shifts: + errors[insufficient_key].append( + insufficient_template.format(employee=employee) + ) + elif num_shifts > max_shifts: + errors[overtime_key].append(overtime_template.format(employee=employee)) + return errors + + +def _validate_employees_per_shift( + results: np.ndarray, + shifts: list[str], + shift_min: int, + shift_max: int, + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates the number of employees per shift for the solution and updates + the `errors` dictionary with any errors found. Requires the `msgs` dict + to have the keys `'understaffed'` and `'overstaffed'`.""" + for s, shift in enumerate(shifts): + understaffed_key, understaffed_template = msgs["understaffed"] + overstaffed_key, overstaffed_template = msgs["overstaffed"] + num_employees = results[:, s].sum() + if num_employees < shift_min: + errors[understaffed_key].append(understaffed_template.format(day=shift)) + elif num_employees > shift_max: + errors[overstaffed_key].append(overstaffed_template.format(day=shift)) + return errors + + +def _validate_requires_manager( + results: np.ndarray, + employees: list[str], + shifts: list[str], + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates the number of managers per shift for the solution and updates + the `errors` dictionary with any errors found. Requires the `msgs` dict + to have the key `'manager_issue'`.""" + key, template = msgs["manager_issue"] + employee_arr = np.asarray( + [employees.index(e) for e in employees if e[-3:] == "Mgr"] + ) + managers_per_shift = results[employee_arr].sum(axis=0) + for shift, num_managers in enumerate(managers_per_shift): + if num_managers == 0: + errors[key].append(template.format(day=shifts[shift])) + return errors + + +def _validate_isolated_days_off( + results: np.ndarray, + employees: list[str], + shifts: list[str], + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates the number of managers per shift for the solution and updates + the `errors` dictionary with any errors found. Requires the `msgs` dict + to have the key `'isolated'`.""" + key, template = msgs["isolated"] + isolated_pattern = np.array([1, 0, 1]) + for e, employee in enumerate(employees): + shift_triples = [results[e, i : i + 3] for i in range(results.shape[1] - 2)] + for s, shift_set in enumerate(shift_triples): + if np.equal(shift_set, isolated_pattern).all(): + shift = shifts[s + 1] + errors[key].append(template.format(employee=employee, day=shift)) + return errors + + +def _validate_max_consecutive_shifts( + results: np.ndarray, + employees: list[str], + shifts: list[str], + max_consecutive_shifts: int, + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates the max number of consecutive shifts for the solution and updates + the `errors` dictionary with any errors found. Requires the `msgs` dict + to have the key `'too_many_consecutive'`.""" + key, template = msgs["too_many_consecutive"] + for e, employee in enumerate(employees): + for shift, shift_arr in enumerate( + [ + results[e, i : i + max_consecutive_shifts] + for i in range(results.shape[1] - max_consecutive_shifts) + ] + ): + if shift_arr.sum() > max_consecutive_shifts: + errors[key].append( + template.format(employee=employee, day=shifts[shift]) + ) + break + return errors + + +def _validate_trainee_shifts( + results: np.ndarray, + employees: list[str], + shifts: list[str], + errors: defaultdict[str, list[str]], + msgs: dict[str, tuple[str, str]], +) -> defaultdict[str, list[str]]: + """Validates that trainees are on-shift with their manager for the solution + and updates the `errors` dictionary with any errors found. Requires the + `msgs` dict to have the key `'trainee_issue'`.""" + key, template = msgs["trainee_issue"] + trainees = {employees.index(e): e for e in employees if e[-2:] == "Tr"} + trainers = { + employees.index(e): e for e in employees if e + "-Tr" in trainees.values() + } + for (trainee_i, trainee), (trainer_i, trainer) in zip( + trainees.items(), trainers.items() + ): + same_shifts = np.less_equal(results[trainee_i], results[trainer_i]) + for i, s in enumerate(same_shifts): + if not s: + errors[key].append(template.format(day=shifts[i])) + return errors From 680f8bb74d1d45fea6bc090336bf7572b3666981 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 15:04:37 -0700 Subject: [PATCH 08/49] refactor `msgs` to module constant --- employee_scheduling.py | 100 ++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index ce88a49..1947e8a 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -19,10 +19,9 @@ ConstrainedQuadraticModel, quicksum, ) -from dwave.optimization.model import ( - Model -) +from dwave.optimization.model import Model from dwave.optimization.mathematical import add +from dwave.optimization.symbols import BinaryVariable from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler import numpy as np @@ -227,7 +226,7 @@ def run_cqm(cqm): def build_nl( availability: dict[str, list[int]], - shifts: dict[str], + shifts: list[str], min_shifts: int, max_shifts: int, shift_min: int, @@ -235,7 +234,32 @@ def build_nl( requires_manager: bool, allow_isolated_days_off: bool, max_consecutive_shifts: int, -) -> Model: +) -> tuple[Model, BinaryVariable]: + """Builds an employee scheduling nonlinear model. + + Args: + availability (dict[str, list[int]]): employee availability, structured + as a dictionary like the following: + ``` + availability = {"Employee Name": [ + 0, # 0 if employee is unavailable + 1, # 1 if employee is available + 2, # 2 if employee prefers this shift + ... + ]} + ``` + shifts (list[str]): list of shift labels + min_shifts (int): min shifts scheduled per employee + max_shifts (int): max shifts scheduled per employee + shift_min (int): min employees scheduled per shift + shift_max (int): max employees scheduled per shift + requires_manager (bool): whether a manager is required on every shift + allow_isolated_days_off (bool): whether to allow an isolated day off + max_consecutive_shifts (int): maximum number of shifts in a row an employee is allowed to work + + Returns: + tuple[Model, BinaryVariable]: the NL model and assignments decision variable + """ # Create list of employees employees = list(availability.keys()) model = Model() @@ -248,6 +272,8 @@ def build_nl( # Create availability constant availability_list = [availability[employee] for employee in employees] + for i, sublist in enumerate(availability_list): + availability_list[i] = [a if a != 2 else 100 for a in sublist] availability_const = model.constant(availability_list) # Initialize model constants @@ -255,6 +281,8 @@ def build_nl( max_shifts_constant = model.constant(max_shifts) shift_min_constant = model.constant(shift_min) shift_max_constant = model.constant(shift_max) + max_consecutive_shifts_c = model.constant(max_consecutive_shifts) + one_c = model.constant(1) # OBJECTIVES: # Objective: give employees preferred schedules (val = 2) @@ -262,45 +290,73 @@ def build_nl( # Objective: for infeasible solutions, focus on right number of shifts for employees target_shifts = model.constant((min_shifts + max_shifts) / 2) - shift_difference_list = [(assignments[e,:].sum() - target_shifts) ** 2 - for e in num_employees] + shift_difference_list = [ + (assignments[e, :].sum() - target_shifts) ** 2 for e in range(num_employees) + ] obj += add(*shift_difference_list) + model.minimize(-obj) # CONSTRAINTS: # Only schedule employees when they're available - model.add_constraint(assignments >= availability_const) + model.add_constraint((availability_const >= assignments).all()) - for e, employee in enumerate(employees): + for e in range(len(employees)): # Schedule employees for at most max_shifts - model.add_constraint(assignments[e,:].sum() <= max_shifts_constant) + model.add_constraint(assignments[e, :].sum() <= max_shifts_constant) # Schedule employees for at least min_shifts - model.add_constraint(assignments[e,:].sum() >= min_shifts_constant) + model.add_constraint(assignments[e, :].sum() >= min_shifts_constant) # Every shift needs shift_min and shift_max employees working for s in range(num_shifts): - model.add_constraint(assignments[:,s].sum() <= shift_max_constant) - model.add_constraint(assignments[:,s].sum() >= shift_min_constant) + model.add_constraint(assignments[:, s].sum() <= shift_max_constant) + model.add_constraint(assignments[:, s].sum() >= shift_min_constant) + + managers_c = model.constant( + [employees.index(e) for e in employees if e[-3:] == "Mgr"] + ) + trainees_c = model.constant( + [employees.index(e) for e in employees if e[-2:] == "Tr"] + ) - # Days off must be consecutive if not allow_isolated_days_off: - # middle range shifts - pattern 101 penalized - ... + negthree_c = model.constant(-3) + zero_c = model.constant(0) + # Adding many small constraints greatly improves feasibility + for e in range(len(employees)): + for s1 in range(len(shifts) - 2): + s2, s3 = s1 + 1, s1 + 2 + model.add_constraint( + negthree_c * assignments[e, s2] + + assignments[e][s1] * assignments[e][s2] + + assignments[e][s1] * assignments[e][s3] + + assignments[e][s2] * assignments[e][s3] + <= zero_c + ) - # Require a manager on every shift if requires_manager: - ... + for shift in range(len(shifts)): + model.add_constraint(assignments[managers_c][:, shift].sum() >= one_c) # Don't exceed max_consecutive_shifts + for e in range(num_employees): + for s in range(num_shifts - max_consecutive_shifts + 1): + s_window = s + max_consecutive_shifts + 1 + model.add_constraint( + assignments[e][s : s_window + 1].sum() <= max_consecutive_shifts_c + ) # Trainee must work on shifts with trainer + trainers = [] + for i in trainees_c.state(): + trainer_name = employees[int(i)][:-3] + trainers.append(employees.index(trainer_name)) + trainers_c = model.constant(trainers) - return model - + model.add_constraint((assignments[trainees_c] <= assignments[trainers_c]).all()) -def run_nl(nl: Model) -> tuple[Model, defaultdict[str, list[str]]]: - ... + return model, assignments if __name__ == '__main__': From 63595ad1775341ba55ad104c063da5ca10a867a9 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 15:24:13 -0700 Subject: [PATCH 09/49] add ModelParams dataclass --- employee_scheduling.py | 106 +++++++++++++++++++++++++---------------- 1 file changed, 65 insertions(+), 41 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 1947e8a..a6420c3 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -25,8 +25,47 @@ from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler import numpy as np -from utils import DAYS, SHIFTS - +from utils import DAYS, SHIFTS, validate_nl_schedule + + +MSGS = { + "unavailable": ( + "Employees scheduled when unavailable", + "{employee} on {day}" + ), + "overtime": ( + "Employees with scheduled overtime", + "{employee}" + ), + "insufficient": ( + "Employees with not enough scheduled time", + "{employee}" + ), + "understaffed": ( + "Understaffed shifts", + "{day} is understaffed" + ), + "overstaffed": ( + "Overstaffed shifts", + "{day} is overstaffed" + ), + "isolated": ( + "Isolated shifts", + "{day} is an isolated day off for {employee}" + ), + "manager_issue": ( + "Shifts with no manager", + "No manager scheduled on {day}" + ), + "too_many_consecutive": ( + "Employees with too many consecutive shifts", + "{employee} starting with {day}" + ), + "trainee_issue": ( + "Shifts with trainee scheduling issues", + "Trainee scheduling issue on {day}" + ), +} def build_cqm( availability, @@ -144,7 +183,7 @@ def build_cqm( return cqm -def run_cqm(cqm): +def run_cqm(cqm, msgs=MSGS): """Run the provided CQM on the Leap Hybrid CQM Sampler.""" sampler = LeapHybridCQMSampler() @@ -162,44 +201,6 @@ def run_cqm(cqm): if s_vals == {0.0}: sampleset.first.sample[list(cqm.variables)[0]] = 1.0 - msgs = { - "unavailable": ( - "Employees scheduled when unavailable", - "{employee} on {day}" - ), - "overtime": ( - "Employees with scheduled overtime", - "{employee}" - ), - "insufficient": ( - "Employees with not enough scheduled time", - "{employee}" - ), - "understaffed": ( - "Understaffed shifts", - "{day} is understaffed" - ), - "overstaffed": ( - "Overstaffed shifts", - "{day} is overstaffed" - ), - "isolated": ( - "Isolated shifts", - "{day} is an isolated day off for {employee}" - ), - "manager_issue": ( - "Shifts with no manager", - "No manager scheduled on {day}" - ), - "too_many_consecutive": ( - "Employees with too many consecutive shifts", - "{employee} starting with {day}" - ), - "trainee_issue": ( - "Shifts with trainee scheduling issues", - "Trainee scheduling issue on {day}" - ), - } for i, _ in enumerate(sat_array): if not sat_array[i]: key, *data = sampleset.info["constraint_labels"][i].split(",") @@ -359,5 +360,28 @@ def build_nl( return model, assignments +# def run_nl( +# model: Model, +# assignments: BinaryVariable, +# time_limit: int | None = None, +# ) -> BinaryVariable, defaultdict[str, list[str]]: +# """Solves the NL scheduling model and detects any errors. + +# Args: +# model (Model): NL model to solve +# assignments (BinaryVariable): decision variable for employee shifts +# time_limit (int | None, optional): time limit for sampling. Defaults to None. +# """ +# if not model.is_locked(): +# model.lock() + +# sampler = LeapHybridNLSampler() +# sampler.sample(model, time_limit=time_limit) +# if solution_printer is not None: +# solution_printer(assignments) +# else: +# print(model.state_size()) + + if __name__ == '__main__': ... \ No newline at end of file From a65c2f6b60476bb771277c9ed62ce2e1d4fb7716 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 15:37:47 -0700 Subject: [PATCH 10/49] refactor build_nl to use ModelParams --- employee_scheduling.py | 153 ++++++++++++++++++----------------------- 1 file changed, 67 insertions(+), 86 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index a6420c3..e6c45e5 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. from collections import defaultdict +from dataclasses import dataclass +from typing import Optional from dimod import ( Binary, @@ -67,80 +69,84 @@ ), } -def build_cqm( - availability, - shifts, - min_shifts, - max_shifts, - shift_min, - shift_max, - requires_manager, - allow_isolated_days_off, - max_consecutive_shifts, -): +@dataclass +class ModelParams: + availability: dict[str, list[int]] + shifts: list[str] + min_shifts: int + max_shifts: int + shift_min: int + shift_max: int + requires_manager: bool + allow_isolated_days_off: bool + max_consecutive_shifts: int + +def build_cqm(params: ModelParams): """Builds the ConstrainedQuadraticModel for the given scenario.""" cqm = ConstrainedQuadraticModel() - employees = list(availability.keys()) + employees = list(params.availability.keys()) # Create variables: one per employee per shift - x = {(employee, shift): Binary(employee + "_" + shift) for shift in shifts for employee in employees} + x = {(employee, shift): Binary(employee + "_" + shift) + for shift in params.shifts + for employee in employees} # OBJECTIVES: # Objective: give employees preferred schedules (val = 2) obj = BinaryQuadraticModel(vartype="BINARY") - for employee, schedule in availability.items(): - for i, shift in enumerate(shifts): + for employee, schedule in params.availability.items(): + for i, shift in enumerate(params.shifts): if schedule[i] == 2: obj += -x[employee, shift] # Objective: for infeasible solutions, focus on right number of shifts for employees - num_s = (min_shifts + max_shifts) / 2 + num_s = (params.min_shifts + params.max_shifts) / 2 for employee in employees: obj += ( - quicksum(x[employee, shift] for shift in shifts) - num_s + quicksum(x[employee, shift] for shift in params.shifts) - num_s ) ** 2 cqm.set_objective(obj) # CONSTRAINTS: # Only schedule employees when they're available - for employee, schedule in availability.items(): - for i, shift in enumerate(shifts): + for employee, schedule in params.availability.items(): + for i, shift in enumerate(params.shifts): if schedule[i] == 0: cqm.add_constraint(x[employee, shift] == 0, label=f"unavailable,{employee},{shift}") for employee in employees: # Schedule employees for at most max_shifts cqm.add_constraint( - quicksum(x[employee, shift] for shift in shifts) - <= max_shifts, + quicksum(x[employee, shift] for shift in params.shifts) + <= params.max_shifts, label=f"overtime,{employee},", ) # Schedule employees for at least min_shifts cqm.add_constraint( - quicksum(x[employee, shift] for shift in shifts) - >= min_shifts, + quicksum(x[employee, shift] for shift in params.shifts) + >= params.min_shifts, label=f"insufficient,{employee},", ) # Every shift needs shift_min and shift_max employees working - for shift in shifts: + for shift in params.shifts: cqm.add_constraint( - sum(x[employee, shift] for employee in employees) >= shift_min, + sum(x[employee, shift] for employee in employees) >= params.shift_min, label=f"understaffed,,{shift}", ) cqm.add_constraint( - sum(x[employee, shift] for employee in employees) <= shift_max, + sum(x[employee, shift] for employee in employees) <= params.shift_max, label=f"overstaffed,,{shift}", ) # Days off must be consecutive - if not allow_isolated_days_off: + if not params.allow_isolated_days_off: # middle range shifts - pattern 101 penalized - for i, prev_shift in enumerate(shifts[:-2]): - shift = shifts[i + 1] - next_shift = shifts[i + 2] + for i, prev_shift in enumerate(params.shifts[:-2]): + shift = params.shifts[i + 1] + next_shift = params.shifts[i + 2] for employee in employees: cqm.add_constraint( -3 * x[employee, shift] @@ -152,9 +158,9 @@ def build_cqm( ) # Require a manager on every shift - if requires_manager: + if params.requires_manager: managers = [employee for employee in employees if employee[-3:] == "Mgr"] - for shift in shifts: + for shift in params.shifts: cqm.add_constraint( quicksum(x[manager, shift] for manager in managers) == 1, label=f"manager_issue,,{shift}", @@ -162,17 +168,17 @@ def build_cqm( # Don't exceed max_consecutive_shifts for employee in employees: - for s in range(len(shifts) - max_consecutive_shifts + 1): + for s in range(len(params.shifts) - params.max_consecutive_shifts + 1): cqm.add_constraint( quicksum( - [x[employee, shifts[s + i]] for i in range(max_consecutive_shifts)] - ) <= max_consecutive_shifts - 1, - label=f"too_many_consecutive,{employee},{shifts[s]}", + [x[employee, params.shifts[s + i]] for i in range(params.max_consecutive_shifts)] + ) <= params.max_consecutive_shifts - 1, + label=f"too_many_consecutive,{employee},{params.shifts[s]}", ) # Trainee must work on shifts with trainer trainees = [employee for employee in employees if employee[-2:] == "Tr"] - for shift in shifts: + for shift in params.shifts: cqm.add_constraint( x[trainees[0], shift] - x[trainees[0], shift] * x[trainees[0][:-3], shift] @@ -225,64 +231,37 @@ def run_cqm(cqm, msgs=MSGS): return feasible_sampleset, None -def build_nl( - availability: dict[str, list[int]], - shifts: list[str], - min_shifts: int, - max_shifts: int, - shift_min: int, - shift_max: int, - requires_manager: bool, - allow_isolated_days_off: bool, - max_consecutive_shifts: int, -) -> tuple[Model, BinaryVariable]: +def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: """Builds an employee scheduling nonlinear model. Args: - availability (dict[str, list[int]]): employee availability, structured - as a dictionary like the following: - ``` - availability = {"Employee Name": [ - 0, # 0 if employee is unavailable - 1, # 1 if employee is available - 2, # 2 if employee prefers this shift - ... - ]} - ``` - shifts (list[str]): list of shift labels - min_shifts (int): min shifts scheduled per employee - max_shifts (int): max shifts scheduled per employee - shift_min (int): min employees scheduled per shift - shift_max (int): max employees scheduled per shift - requires_manager (bool): whether a manager is required on every shift - allow_isolated_days_off (bool): whether to allow an isolated day off - max_consecutive_shifts (int): maximum number of shifts in a row an employee is allowed to work + params (ModelParams): model parameters Returns: tuple[Model, BinaryVariable]: the NL model and assignments decision variable """ # Create list of employees - employees = list(availability.keys()) + employees = list(params.availability.keys()) model = Model() # Create a binary symbol representing the assignment of employees to shifts # i.e. assignments[employee][shift] = 1 if assigned, else 0 num_employees = len(employees) - num_shifts = len(shifts) + num_shifts = len(params.shifts) assignments = model.binary((num_employees, num_shifts)) # Create availability constant - availability_list = [availability[employee] for employee in employees] + availability_list = [params.availability[employee] for employee in employees] for i, sublist in enumerate(availability_list): availability_list[i] = [a if a != 2 else 100 for a in sublist] availability_const = model.constant(availability_list) # Initialize model constants - min_shifts_constant = model.constant(min_shifts) - max_shifts_constant = model.constant(max_shifts) - shift_min_constant = model.constant(shift_min) - shift_max_constant = model.constant(shift_max) - max_consecutive_shifts_c = model.constant(max_consecutive_shifts) + min_shifts_constant = model.constant(params.min_shifts) + max_shifts_constant = model.constant(params.max_shifts) + shift_min_constant = model.constant(params.shift_min) + shift_max_constant = model.constant(params.shift_max) + max_consecutive_shifts_c = model.constant(params.max_consecutive_shifts) one_c = model.constant(1) # OBJECTIVES: @@ -290,7 +269,7 @@ def build_nl( obj = (assignments * availability_const).sum() # Objective: for infeasible solutions, focus on right number of shifts for employees - target_shifts = model.constant((min_shifts + max_shifts) / 2) + target_shifts = model.constant((params.min_shifts + params.max_shifts) / 2) shift_difference_list = [ (assignments[e, :].sum() - target_shifts) ** 2 for e in range(num_employees) ] @@ -321,12 +300,12 @@ def build_nl( [employees.index(e) for e in employees if e[-2:] == "Tr"] ) - if not allow_isolated_days_off: + if not params.allow_isolated_days_off: negthree_c = model.constant(-3) zero_c = model.constant(0) # Adding many small constraints greatly improves feasibility for e in range(len(employees)): - for s1 in range(len(shifts) - 2): + for s1 in range(len(params.shifts) - 2): s2, s3 = s1 + 1, s1 + 2 model.add_constraint( negthree_c * assignments[e, s2] @@ -336,14 +315,14 @@ def build_nl( <= zero_c ) - if requires_manager: - for shift in range(len(shifts)): + if params.requires_manager: + for shift in range(len(params.shifts)): model.add_constraint(assignments[managers_c][:, shift].sum() >= one_c) # Don't exceed max_consecutive_shifts for e in range(num_employees): - for s in range(num_shifts - max_consecutive_shifts + 1): - s_window = s + max_consecutive_shifts + 1 + for s in range(num_shifts - params.max_consecutive_shifts + 1): + s_window = s + params.max_consecutive_shifts + 1 model.add_constraint( assignments[e][s : s_window + 1].sum() <= max_consecutive_shifts_c ) @@ -363,8 +342,12 @@ def build_nl( # def run_nl( # model: Model, # assignments: BinaryVariable, +# availability: dict[str, list[int]], +# shifts: list[str], +# min # time_limit: int | None = None, -# ) -> BinaryVariable, defaultdict[str, list[str]]: +# msgs: dict[str, tuple[str, str]] = MSGS +# ) -> tuple[BinaryVariable, Optional[defaultdict[str, list[str]]]]: # """Solves the NL scheduling model and detects any errors. # Args: @@ -377,10 +360,8 @@ def build_nl( # sampler = LeapHybridNLSampler() # sampler.sample(model, time_limit=time_limit) -# if solution_printer is not None: -# solution_printer(assignments) -# else: -# print(model.state_size()) +# errors = validate_nl_schedule(assignments, ava) + if __name__ == '__main__': From 513c81516e5fa298165e823a1e4cc9695fdd0c0a Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 15:40:44 -0700 Subject: [PATCH 11/49] move ModelParams to utils --- employee_scheduling.py | 14 +------------- utils.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index e6c45e5..98f5461 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. from collections import defaultdict -from dataclasses import dataclass from typing import Optional from dimod import ( @@ -27,7 +26,7 @@ from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler import numpy as np -from utils import DAYS, SHIFTS, validate_nl_schedule +from utils import DAYS, SHIFTS, ModelParams, validate_nl_schedule MSGS = { @@ -69,17 +68,6 @@ ), } -@dataclass -class ModelParams: - availability: dict[str, list[int]] - shifts: list[str] - min_shifts: int - max_shifts: int - shift_min: int - shift_max: int - requires_manager: bool - allow_isolated_days_off: bool - max_consecutive_shifts: int def build_cqm(params: ModelParams): """Builds the ConstrainedQuadraticModel for the given scenario.""" diff --git a/utils.py b/utils.py index 215628e..4143c6a 100644 --- a/utils.py +++ b/utils.py @@ -16,6 +16,7 @@ import random import string from collections import defaultdict +from dataclasses import dataclass from app_configs import REQUESTED_SHIFT_ICON, UNAVAILABLE_ICON import numpy as np @@ -37,6 +38,18 @@ DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] WEEKEND_IDS = ["1", "7", "8", "14"] +@dataclass +class ModelParams: + availability: dict[str, list[int]] + shifts: list[str] + min_shifts: int + max_shifts: int + shift_min: int + shift_max: int + requires_manager: bool + allow_isolated_days_off: bool + max_consecutive_shifts: int + def get_random_string(length): """Generate a random string of a given length.""" From aff21f35d58d39273d8fb8da954db6a598d983f0 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 16:28:32 -0700 Subject: [PATCH 12/49] update validation functions to use ModelParams --- utils.py | 124 +++++++++++++++++++++++++++---------------------------- 1 file changed, 60 insertions(+), 64 deletions(-) diff --git a/utils.py b/utils.py index 4143c6a..6396795 100644 --- a/utils.py +++ b/utils.py @@ -16,7 +16,7 @@ import random import string from collections import defaultdict -from dataclasses import dataclass +from dataclasses import asdict, dataclass from app_configs import REQUESTED_SHIFT_ICON, UNAVAILABLE_ICON import numpy as np @@ -40,6 +40,31 @@ @dataclass class ModelParams: + """Convenience class for defining and passing model parameters. + + Args: + availability (dict[str, list[int]]): Employee availability for each shift, + structured as follows: + ``` + availability = { + 'Employee Name': [ + 0, # 0 if unavailable for shift at index i + 1, # 1 if availabile for shift at index i + 2, # 2 if shift at index i is preferred + ... + ] + } + ``` + shifts (list[str]): List of shift labels. + min_shifts (int): Min shifts per employee. + max_shifts (int): Max shifts per employee. + shift_min (int): Min employees per shift. + shift_max (int): Max employees per shift. + requires_manager (bool): Whether a manager is required on every shift. + allow_isolated_days_off (bool): Whether isolated shifts off are allowed + (pattern of on-off-on). + max_consecutive_shifts (int): Max consecutive shifts for each employee. + """ availability: dict[str, list[int]] shifts: list[str] min_shifts: int @@ -272,15 +297,7 @@ def availability_to_dict(availability_list): def validate_nl_schedule( assignments: BinaryVariable, - availability: dict[str, list[int]], - shifts: list[str], - min_shifts: int, - max_shifts: int, - shift_min: int, - shift_max: int, - requires_manager: bool, - allow_isolated_days_off: bool, - max_consecutive_shifts: int, + params: ModelParams, msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: """Detect any errors in a solved NL scheduling model. @@ -292,19 +309,7 @@ def validate_nl_schedule( Args: assignments (BinaryVariable): Assignments generated from sampling the NL model. - availability (dict[str, list[int]]): Employee availability used as input - when building the NL model. - shifts (list[str]): Shift labels used as input when building the NL - model. - min_shifts (int): Min shifts to be scheduled per employee. - max_shifts (int): Max shifts to be scheduled per employee. - shift_min (int): Min employees to be scheduled per shift. - shift_max (int): Max employees to be scheduled per shift. - requires_manager (bool): Whether a manager is required on every shift. - allow_isolated_days_off (bool): Whether to allow isolated unscheduled - shifts (pattern on-off-on). - max_consecutive_shifts (int): Maximum amount of shifts in a row an - employee can be scheduled. + params (ModelParams): Model parameters. msgs (dict[str, tuple[str, str]]): Error message template dictionary. Must be formatted like: ``` @@ -336,34 +341,29 @@ def validate_nl_schedule( if key not in msgs: raise ValueError(f"`msgs` dictionary missing required key `{key}`") - # Pull solution state as ndarray + # Pull solution state as ndarray, employees as list result = assignments.state() - employees = list(availability.keys()) + employees = list(params.availability.keys()) errors = defaultdict(list) - _validate_availability(result, availability, employees, shifts, errors, msgs) - _validate_shifts_per_employee( - result, employees, min_shifts, max_shifts, errors, msgs - ) - _validate_employees_per_shift(result, shifts, shift_min, shift_max, errors, msgs) - _validate_max_consecutive_shifts( - result, employees, shifts, max_consecutive_shifts, errors, msgs - ) - _validate_trainee_shifts(result, employees, shifts, errors, msgs) - if requires_manager: - _validate_requires_manager(result, employees, shifts, errors, msgs) - if not allow_isolated_days_off: - _validate_isolated_days_off(result, employees, shifts, errors, msgs) + _validate_availability(params, result, employees, errors, msgs) + _validate_shifts_per_employee(params, result, employees, errors, msgs) + _validate_employees_per_shift(params, result, errors, msgs) + _validate_max_consecutive_shifts(params, result, employees, errors, msgs) + _validate_trainee_shifts(params, result, employees, errors, msgs) + if params.requires_manager: + _validate_requires_manager(params, result, employees, errors, msgs) + if not params.allow_isolated_days_off: + _validate_isolated_days_off(params, result, employees, errors, msgs) return errors def _validate_availability( + params: ModelParams, results: np.ndarray, - availability: dict[str, list[int]], employees: list[str], - shifts: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -372,8 +372,8 @@ def _validate_availability( key `'unavailable'`.""" msg_key, msg_template = msgs["unavailable"] for e, employee in enumerate(employees): - for s, shift in enumerate(shifts): - if results[e, s] > availability[employee][s]: + for s, shift in enumerate(params.shifts): + if results[e, s] > params.availability[employee][s]: errors[msg_key].append( msg_template.format(employee=employee, day=shift) ) @@ -381,10 +381,9 @@ def _validate_availability( def _validate_shifts_per_employee( + params: ModelParams, results: np.ndarray, employees: list[str], - min_shifts: int, - max_shifts: int, errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -395,41 +394,39 @@ def _validate_shifts_per_employee( overtime_key, overtime_template = msgs["overtime"] for e, employee in enumerate(employees): num_shifts = results[e, :].sum() - if num_shifts < min_shifts: + if num_shifts < params.min_shifts: errors[insufficient_key].append( insufficient_template.format(employee=employee) ) - elif num_shifts > max_shifts: + elif num_shifts > params.max_shifts: errors[overtime_key].append(overtime_template.format(employee=employee)) return errors def _validate_employees_per_shift( + params: ModelParams, results: np.ndarray, - shifts: list[str], - shift_min: int, - shift_max: int, errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: """Validates the number of employees per shift for the solution and updates the `errors` dictionary with any errors found. Requires the `msgs` dict to have the keys `'understaffed'` and `'overstaffed'`.""" - for s, shift in enumerate(shifts): + for s, shift in enumerate(params.shifts): understaffed_key, understaffed_template = msgs["understaffed"] overstaffed_key, overstaffed_template = msgs["overstaffed"] num_employees = results[:, s].sum() - if num_employees < shift_min: + if num_employees < params.shift_min: errors[understaffed_key].append(understaffed_template.format(day=shift)) - elif num_employees > shift_max: + elif num_employees > params.shift_max: errors[overstaffed_key].append(overstaffed_template.format(day=shift)) return errors def _validate_requires_manager( + params: ModelParams, results: np.ndarray, employees: list[str], - shifts: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -443,14 +440,14 @@ def _validate_requires_manager( managers_per_shift = results[employee_arr].sum(axis=0) for shift, num_managers in enumerate(managers_per_shift): if num_managers == 0: - errors[key].append(template.format(day=shifts[shift])) + errors[key].append(template.format(day=params.shifts[shift])) return errors def _validate_isolated_days_off( + params: ModelParams, results: np.ndarray, employees: list[str], - shifts: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -463,16 +460,15 @@ def _validate_isolated_days_off( shift_triples = [results[e, i : i + 3] for i in range(results.shape[1] - 2)] for s, shift_set in enumerate(shift_triples): if np.equal(shift_set, isolated_pattern).all(): - shift = shifts[s + 1] + shift = params.shifts[s + 1] errors[key].append(template.format(employee=employee, day=shift)) return errors def _validate_max_consecutive_shifts( + params: ModelParams, results: np.ndarray, employees: list[str], - shifts: list[str], - max_consecutive_shifts: int, errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -483,22 +479,22 @@ def _validate_max_consecutive_shifts( for e, employee in enumerate(employees): for shift, shift_arr in enumerate( [ - results[e, i : i + max_consecutive_shifts] - for i in range(results.shape[1] - max_consecutive_shifts) + results[e, i : i + params.max_consecutive_shifts] + for i in range(results.shape[1] - params.max_consecutive_shifts) ] ): - if shift_arr.sum() > max_consecutive_shifts: + if shift_arr.sum() > params.max_consecutive_shifts: errors[key].append( - template.format(employee=employee, day=shifts[shift]) + template.format(employee=employee, day=params.shifts[shift]) ) break return errors def _validate_trainee_shifts( + params: ModelParams, results: np.ndarray, employees: list[str], - shifts: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -516,5 +512,5 @@ def _validate_trainee_shifts( same_shifts = np.less_equal(results[trainee_i], results[trainer_i]) for i, s in enumerate(same_shifts): if not s: - errors[key].append(template.format(day=shifts[i])) + errors[key].append(template.format(day=params.shifts[i])) return errors From 99d1abe98d27674179fe981b8729a6842cc052e3 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 16:39:57 -0700 Subject: [PATCH 13/49] remove unused import --- utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils.py b/utils.py index 6396795..c832fbe 100644 --- a/utils.py +++ b/utils.py @@ -16,7 +16,7 @@ import random import string from collections import defaultdict -from dataclasses import asdict, dataclass +from dataclasses import dataclass from app_configs import REQUESTED_SHIFT_ICON, UNAVAILABLE_ICON import numpy as np From 6a3c9e013607ed6c23990ff0d1faef571be3c7ed Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 13 Sep 2024 16:58:05 -0700 Subject: [PATCH 14/49] add time limit heuristic for run_nl --- employee_scheduling.py | 56 ++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 98f5461..9d79ece 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -24,7 +24,6 @@ from dwave.optimization.mathematical import add from dwave.optimization.symbols import BinaryVariable from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler -import numpy as np from utils import DAYS, SHIFTS, ModelParams, validate_nl_schedule @@ -327,30 +326,39 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: return model, assignments -# def run_nl( -# model: Model, -# assignments: BinaryVariable, -# availability: dict[str, list[int]], -# shifts: list[str], -# min -# time_limit: int | None = None, -# msgs: dict[str, tuple[str, str]] = MSGS -# ) -> tuple[BinaryVariable, Optional[defaultdict[str, list[str]]]]: -# """Solves the NL scheduling model and detects any errors. - -# Args: -# model (Model): NL model to solve -# assignments (BinaryVariable): decision variable for employee shifts -# time_limit (int | None, optional): time limit for sampling. Defaults to None. -# """ -# if not model.is_locked(): -# model.lock() - -# sampler = LeapHybridNLSampler() -# sampler.sample(model, time_limit=time_limit) -# errors = validate_nl_schedule(assignments, ava) - +def run_nl( + model: Model, + assignments: BinaryVariable, + params: ModelParams, + time_limit: int | None = None, + msgs: dict[str, tuple[str, str]] = MSGS +) -> Optional[defaultdict[str, list[str]]]: + """Solves the NL scheduling model and detects any errors. + + Args: + model (Model): NL model to solve + assignments (BinaryVariable): decision variable for employee shifts + time_limit (int | None, optional): time limit for sampling. Defaults to None. + """ + if not model.is_locked(): + model.lock() + # If time limit is None, use heuristic of largest `assignments` dimension + # rounded up to 5 + if time_limit is None: + time_limit = (max_dim := max(assignments.shape())) + max_dim % 5 + + sampler = LeapHybridNLSampler() + sampler.sample(model, time_limit=time_limit) + errors = validate_nl_schedule(assignments, params, msgs) + + # Return errors if any error message list is populated + for error_list in errors.values(): + if len(error_list) > 0: + return errors + + return None + if __name__ == '__main__': ... \ No newline at end of file From db02c648578cb6087e1f10862c8592c7e9f691c3 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 12:22:39 -0700 Subject: [PATCH 15/49] update manager constraint to equality instead of geq --- employee_scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 9d79ece..c17ddb8 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -304,7 +304,7 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: if params.requires_manager: for shift in range(len(params.shifts)): - model.add_constraint(assignments[managers_c][:, shift].sum() >= one_c) + model.add_constraint(assignments[managers_c][:, shift].sum() == one_c) # Don't exceed max_consecutive_shifts for e in range(num_employees): From ec1839bceaa3688bfcb1dd86e9935d9bba790109 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 14:48:36 -0700 Subject: [PATCH 16/49] add build_schedule_from_state function --- utils.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/utils.py b/utils.py index c832fbe..96b6dfa 100644 --- a/utils.py +++ b/utils.py @@ -148,6 +148,23 @@ def build_schedule_from_sample(sample, employees): return data + +def build_schedule_from_state(assignments, employees): + """Builds a schedule from the state of a BinaryVariable.""" + state = assignments.state() + data = pd.DataFrame(columns=COL_IDS) + data.insert(0, "Employee", employees) + + for row in range(state.shape[0]): + for col in range(state.shape[1]): + if state[row, col] == 1.0: + data.iloc[row, col] = " " + else: + data.iloc[row, col] = UNAVAILABLE_ICON + + return data + + def get_cols(): """Gets information for column headers, including months and days.""" start_month = START_DATE.strftime("%B %Y") # Get month and year From caed39dbd2c5d258a633bdb74ddf19734c7e6907 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 15:00:19 -0700 Subject: [PATCH 17/49] fix FutureWarning in build_random_sched function --- utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils.py b/utils.py index 96b6dfa..26435e6 100644 --- a/utils.py +++ b/utils.py @@ -126,8 +126,8 @@ def build_random_sched(num_employees, rand_seed=None): data.insert(0, "Employee", employees) - data[COL_IDS[0]].replace(UNAVAILABLE_ICON, " ", inplace=True) - data[COL_IDS[-1]].replace(UNAVAILABLE_ICON, " ", inplace=True) + data.replace({COL_IDS[0]: {UNAVAILABLE_ICON, " "}}) + data.replace({COL_IDS[-1]: {UNAVAILABLE_ICON, " "}}) data.loc[data.Employee == employees[-1], data.columns[1:]] = " " From e3284b0b2e39676f279a03c2b7b6cd4e99d0522c Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 15:24:02 -0700 Subject: [PATCH 18/49] change parameter from BinaryVariable to state ndarray --- utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/utils.py b/utils.py index 26435e6..c1dbbdc 100644 --- a/utils.py +++ b/utils.py @@ -149,9 +149,8 @@ def build_schedule_from_sample(sample, employees): return data -def build_schedule_from_state(assignments, employees): +def build_schedule_from_state(state: np.ndarray, employees: list[str]): """Builds a schedule from the state of a BinaryVariable.""" - state = assignments.state() data = pd.DataFrame(columns=COL_IDS) data.insert(0, "Employee", employees) From 11075f197281f729cdcf8a28e483535979f243ec Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 15:35:13 -0700 Subject: [PATCH 19/49] add unit tests for NL formulation --- tests/test_inputs.py | 150 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 17 deletions(-) diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 2ade57e..3488324 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -14,6 +14,7 @@ import unittest from dash import dash_table +from numpy import asarray import employee_scheduling import utils @@ -21,30 +22,44 @@ class TestDemo(unittest.TestCase): + # Initialize shared data for tests + def setUp(self): + self.num_employees = 12 + self.sched_df = disp_initial_sched(self.num_employees, None)[0].data + self.shifts =list(self.sched_df[0].keys()) + self.shifts.remove("Employee") + self.availability = utils.availability_to_dict(self.sched_df) + self.test_params = utils.ModelParams( + availability=self.availability, + shifts=self.shifts, + min_shifts=1, + max_shifts=6, + shift_min=5, + shift_max=16, + requires_manager=True, + allow_isolated_days_off=False, + max_consecutive_shifts=6 + ) + # Check that initial schedule created is the right size def test_initial_sched(self): - num_employees = 12 - - sched_df = disp_initial_sched(num_employees, None)[0].data - - self.assertEqual(len(sched_df), num_employees) + self.assertEqual(len(self.sched_df), self.num_employees) # Check that CQM created has the right number of variables def test_cqm(self): - num_employees = 12 + cqm = employee_scheduling.build_cqm(self.test_params) - sched_df = disp_initial_sched(num_employees, None)[0].data - shifts = list(sched_df[0].keys()) - shifts.remove("Employee") - availability = utils.availability_to_dict(sched_df) + self.assertEqual(len(cqm.variables), + self.num_employees * len(self.shifts)) - cqm = employee_scheduling.build_cqm( - availability, shifts, 1, 6, 5, 16, True, False, 6 - ) + # Check that NL assignments variable is the correct shape + def test_nl(self): + _, assignments = employee_scheduling.build_nl(self.test_params) - self.assertEqual(len(cqm.variables), num_employees * len(shifts)) + self.assertEqual(assignments.shape(), + (self.num_employees, len(self.shifts))) - def test_samples(self): + def test_samples_cqm(self): shifts = [str(i + 1) for i in range(5)] # Make every employee available for every shift @@ -57,10 +72,20 @@ def test_samples(self): "E-Tr": [1] * 5, } - cqm = employee_scheduling.build_cqm( - availability, shifts, 1, 6, 5, 16, True, False, 6 + test_params = utils.ModelParams( + availability=availability, + shifts=shifts, + min_shifts=1, + max_shifts=6, + shift_min=5, + shift_max=16, + requires_manager=True, + allow_isolated_days_off=False, + max_consecutive_shifts=6 ) + cqm = employee_scheduling.build_cqm(test_params) + feasible_sample = { "A-Mgr_1": 0.0, "A-Mgr_2": 0.0, @@ -130,6 +155,69 @@ def test_samples(self): self.assertTrue(cqm.check_feasible(feasible_sample)) self.assertFalse(cqm.check_feasible(infeasible_sample)) + def test_states_nl(self): + shifts = [str(i + 1) for i in range(5)] + + # Make every employee available for every shift + availability = { + "A-Mgr": [1] * 5, + "B-Mgr": [1] * 5, + "C": [1] * 5, + "D": [1] * 5, + "E": [1] * 5, + "E-Tr": [1] * 5, + } + + test_params = utils.ModelParams( + availability=availability, + shifts=shifts, + min_shifts=1, + max_shifts=6, + shift_min=5, + shift_max=16, + requires_manager=True, + allow_isolated_days_off=False, + max_consecutive_shifts=6 + ) + + model, assignments = employee_scheduling.build_nl(test_params) + + if not model.is_locked(): + model.lock() + + # Resize model states + model.states.resize(2) + + feasible_state = [ + [0, 0, 1, 1, 1], # A-Mgr + [1, 1, 0, 0, 0], # B-Mgr + [1, 1, 1, 1, 1], # C + [1, 1, 1, 1, 1], # D + [1, 1, 1, 1, 1], # E + [1, 1, 1, 1, 1] # E-Tr + ] + + # Infeasible: multiple managers scheduled for the same shift + infeasible_state = [ + [1, 1, 1, 1, 1], # A-Mgr + [1, 1, 1, 1, 1], # B-Mgr + [1, 1, 1, 1, 1], # C + [1, 1, 1, 1, 1], # D + [1, 1, 1, 1, 1], # E + [1, 1, 1, 1, 1] # E-Tr + ] + + # Assign feasible state to index 0 + assignments.set_state(0, feasible_state) + # Assign infeasible state to index 1 + assignments.set_state(1, infeasible_state) + + constraints_0 = [int(c.state(0)) for c in model.iter_constraints()] + constraints_1 = [int(c.state(1)) for c in model.iter_constraints()] + + self.assertEqual(len(constraints_0), sum(constraints_0)) + self.assertGreater(len(constraints_1), sum(constraints_1)) + def test_build_from_sample(self): employees = ["A-Mgr", "B-Mgr", "C", "D", "E", "E-Tr"] @@ -183,3 +271,31 @@ def test_build_from_sample(self): # This should verify we don't have any issues in the object created for display from a sample self.assertEqual(type(disp_datatable), dash_table.DataTable) + + def test_build_from_state(self): + employees = ["A-Mgr", "B-Mgr", "C", "D", "E", "E-Tr"] + + # Make every employee available for every shift + availability = { + "A-Mgr": [1] * 14, + "B-Mgr": [1] * 14, + "C": [1] * 14, + "D": [1] * 14, + "E": [1] * 14, + "E-Tr": [1] * 14, + } + + state = asarray([ + [0, 0, 1, 1, 1], + [1, 1, 0, 0, 0], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1] + ]) + + disp_datatable = utils.display_schedule( + utils.build_schedule_from_state(state, employees), + availability + ) + + self.assertIsInstance(disp_datatable, dash_table.DataTable) \ No newline at end of file From 19161de25f61dac92e96d560693a43636dcf0afb Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 15:57:20 -0700 Subject: [PATCH 20/49] add solver options to app_configs.py --- app_configs.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app_configs.py b/app_configs.py index 701a24c..3bcf8fe 100644 --- a/app_configs.py +++ b/app_configs.py @@ -36,6 +36,11 @@ # Sliders, buttons and option entries # ####################################### +SOLVERS = { + "CQM": "cqm", + "Nonlinear": "nl", +} + # min/max number of shifts per employee range slider (value means default) MIN_MAX_SHIFTS = { "min": 1, From 3e55fc80480ec21440a49290202cffc40915f771 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 16:08:04 -0700 Subject: [PATCH 21/49] add solver selection dropdown to control card --- app_html.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app_html.py b/app_html.py index 768eb48..951ae9f 100644 --- a/app_html.py +++ b/app_html.py @@ -18,7 +18,7 @@ from dash import dcc, html from app_configs import (DESCRIPTION, EXAMPLE_SCENARIO, MAIN_HEADER, MAX_CONSECUTIVE_SHIFTS, MIN_MAX_EMPLOYEES, - MIN_MAX_SHIFTS, NUM_EMPLOYEES, REQUESTED_SHIFT_ICON, THUMBNAIL, UNAVAILABLE_ICON) + MIN_MAX_SHIFTS, NUM_EMPLOYEES, REQUESTED_SHIFT_ICON, THUMBNAIL, UNAVAILABLE_ICON, SOLVERS) def description_card(): @@ -85,6 +85,21 @@ def generate_control_card() -> html.Div: return html.Div( id="control-card", children=[ + html.Div( + children=[ + html.Label("Solver"), + dcc.Dropdown( + id="solver-select", + options=( + solver_options := [{"label": k, "value": v} + for k, v in SOLVERS.items()] + ), + value=solver_options[0], + clearable=False, + searchable=False, + ) + ] + ), html.Div( children=[ html.Label("Scenario preset (sets sliders below)"), From 2ba145a29f6cd9d1cc8e5714decfa6130ee557f3 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 16:35:54 -0700 Subject: [PATCH 22/49] fix max_consecutive_shifts constraint in cqm formulation so that the actual constraint value can be passed directly from app.py --- employee_scheduling.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index c17ddb8..35ed3c0 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -158,8 +158,8 @@ def build_cqm(params: ModelParams): for s in range(len(params.shifts) - params.max_consecutive_shifts + 1): cqm.add_constraint( quicksum( - [x[employee, params.shifts[s + i]] for i in range(params.max_consecutive_shifts)] - ) <= params.max_consecutive_shifts - 1, + [x[employee, params.shifts[s + i]] for i in range(params.max_consecutive_shifts + 1)] + ) <= params.max_consecutive_shifts, label=f"too_many_consecutive,{employee},{params.shifts[s]}", ) From 86e1ec5565ff724974f11b54bcdb4a278cdb3c8f Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 16:59:33 -0700 Subject: [PATCH 23/49] fix Solver dropdown value initialization --- app_html.py | 2 +- employee_scheduling.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app_html.py b/app_html.py index 951ae9f..1259928 100644 --- a/app_html.py +++ b/app_html.py @@ -94,7 +94,7 @@ def generate_control_card() -> html.Div: solver_options := [{"label": k, "value": v} for k, v in SOLVERS.items()] ), - value=solver_options[0], + value=solver_options[0]["value"], clearable=False, searchable=False, ) diff --git a/employee_scheduling.py b/employee_scheduling.py index 35ed3c0..49f79e1 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -155,7 +155,7 @@ def build_cqm(params: ModelParams): # Don't exceed max_consecutive_shifts for employee in employees: - for s in range(len(params.shifts) - params.max_consecutive_shifts + 1): + for s in range(len(params.shifts) - params.max_consecutive_shifts): cqm.add_constraint( quicksum( [x[employee, params.shifts[s + i]] for i in range(params.max_consecutive_shifts + 1)] From 105dd9ab09549ecaa7feea2b59b61e9a11cae9ab Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Tue, 17 Sep 2024 17:01:55 -0700 Subject: [PATCH 24/49] add NL solver to run_optimization callback --- app.py | 37 ++++++++++++++++++++++++++----------- utils.py | 12 ++++++------ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 754b120..c458634 100644 --- a/app.py +++ b/app.py @@ -295,6 +295,7 @@ def update_error_sidebar(run_click: int, prev_classes) -> tuple[dict, str]: State("checklist-input", "value"), State("consecutive-shifts-select", "value"), State("availability-content", "children"), + State("solver-select", "value"), ], running=[ # show cancel button and hide run button, and disable and animate results tab @@ -323,6 +324,7 @@ def run_optimization( checklist: list[int], consecutive_shifts: int, sched_df: DataFrame, + solver: str, ) -> tuple[DataFrame, bool, dict, list]: """Run a job on the hybrid solver when the run button is clicked.""" if run_click == 0 or ctx.triggered_id != "run-button": @@ -337,20 +339,33 @@ def run_optimization( isolated_days_allowed = True if 0 in checklist else False manager_required = True if 1 in checklist else False - cqm = employee_scheduling.build_cqm( - availability, - shifts, - *shifts_per_employee, - *employees_per_shift, - manager_required, - isolated_days_allowed, - consecutive_shifts + 1, + params = utils.ModelParams( + availability=availability, + shifts=shifts, + min_shifts=min(shifts_per_employee), + max_shifts=max(shifts_per_employee), + shift_min=min(employees_per_shift), + shift_max=max(employees_per_shift), + requires_manager=manager_required, + allow_isolated_days_off=isolated_days_allowed, + max_consecutive_shifts=consecutive_shifts ) - feasible_sampleset, errors = employee_scheduling.run_cqm(cqm) - sample = feasible_sampleset.first.sample + if solver == "cqm": + cqm = employee_scheduling.build_cqm(params) - sched = utils.build_schedule_from_sample(sample, employees) + feasible_sampleset, errors = employee_scheduling.run_cqm(cqm) + sample = feasible_sampleset.first.sample + + sched = utils.build_schedule_from_sample(sample, employees) + + elif solver == "nl": + model, assignments = employee_scheduling.build_nl(params) + errors = employee_scheduling.run_nl(model, assignments, params) + sched = utils.build_schedule_from_state(assignments.state(), employees, shifts) + + else: + raise ValueError(f"Solver value `{solver} is unhandled.") return ( utils.display_schedule(sched, availability), diff --git a/utils.py b/utils.py index c1dbbdc..15b755b 100644 --- a/utils.py +++ b/utils.py @@ -149,17 +149,17 @@ def build_schedule_from_sample(sample, employees): return data -def build_schedule_from_state(state: np.ndarray, employees: list[str]): +def build_schedule_from_state(state: np.ndarray, employees: list[str], shifts: list[str]): """Builds a schedule from the state of a BinaryVariable.""" data = pd.DataFrame(columns=COL_IDS) data.insert(0, "Employee", employees) - for row in range(state.shape[0]): - for col in range(state.shape[1]): - if state[row, col] == 1.0: - data.iloc[row, col] = " " + for e, employee in enumerate(employees): + for s, shift in enumerate(shifts): + if state[e, s] == 1.0: + data.loc[data["Employee"] == employee, shift] = " " else: - data.iloc[row, col] = UNAVAILABLE_ICON + data.loc[data["Employee"] == employee, shift] = UNAVAILABLE_ICON return data From 26d5407cc7a5012581661d7c269ec0986eace814 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 09:26:26 -0700 Subject: [PATCH 25/49] fix error message string --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index c458634..c528acc 100644 --- a/app.py +++ b/app.py @@ -365,7 +365,7 @@ def run_optimization( sched = utils.build_schedule_from_state(assignments.state(), employees, shifts) else: - raise ValueError(f"Solver value `{solver} is unhandled.") + raise ValueError(f"Solver value `{solver}` is unhandled.") return ( utils.display_schedule(sched, availability), From bba40214a23dee3cea73f8ab2982a9dc5d7a0ad6 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 09:27:20 -0700 Subject: [PATCH 26/49] add .venv to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b59c1b0..cd57986 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.py[cod] *$py.class cache.db* +.venv From d1f4bb62c65db5743c97a93fb0786d8adc06ca2e Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 09:47:28 -0700 Subject: [PATCH 27/49] delete nl_formulation.py development file --- nl_formulation.py | 390 ---------------------------------------------- 1 file changed, 390 deletions(-) delete mode 100644 nl_formulation.py diff --git a/nl_formulation.py b/nl_formulation.py deleted file mode 100644 index 31e1d80..0000000 --- a/nl_formulation.py +++ /dev/null @@ -1,390 +0,0 @@ -from typing import Callable - -import numpy as np -from dwave.optimization.mathematical import add -from dwave.optimization.model import Model -from dwave.optimization.symbols import BinaryVariable -from dwave.system import LeapHybridNLSampler -from tabulate import tabulate -from colorama import Fore, Style - -availability = { - "Marcus K-Mgr": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], - "Robert C-Mgr": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2], - "Jonathan B": [1, 1, 0, 0, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2], - "Thomas U": [1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 2, 0, 1, 1], - "Herbert I": [1, 2, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1], - "Donna Z": [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1], - "Karen T": [1, 1, 1, 1, 2, 1, 0, 1, 1, 1, 1, 2, 1, 1], - "Seth F": [1, 2, 1, 1, 0, 2, 1, 1, 1, 1, 1, 1, 1, 1], - "Stephanie F": [1, 1, 2, 2, 1, 1, 2, 1, 0, 1, 1, 1, 1, 1], - "Casey B": [1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 2], - "Mike P": [1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1], - "Mike P-Tr": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], -} - -shifts = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14"] - -min_shifts = 5 -max_shifts = 7 -shift_min = 3 -shift_max = 6 -requires_manager = True -allow_isolated_days_off = False -max_consecutive_shifts = 6 - - -def build_nl( - availability: dict[str, list[int]], - shifts: list[str], - min_shifts: int, - max_shifts: int, - shift_min: int, - shift_max: int, - requires_manager: bool, - allow_isolated_days_off: bool, - max_consecutive_shifts: int, -) -> tuple[Model, BinaryVariable]: - # Create list of employees - employees = list(availability.keys()) - model = Model() - - # Create a binary symbol representing the assignment of employees to shifts - # i.e. assignments[employee][shift] = 1 if assigned, else 0 - num_employees = len(employees) - num_shifts = len(shifts) - assignments = model.binary((num_employees, num_shifts)) - - # Create availability constant - availability_list = [availability[employee] for employee in employees] - for i, sublist in enumerate(availability_list): - availability_list[i] = [a if a != 2 else 100 for a in sublist] - availability_const = model.constant(availability_list) - - # Initialize model constants - min_shifts_constant = model.constant(min_shifts) - max_shifts_constant = model.constant(max_shifts) - shift_min_constant = model.constant(shift_min) - shift_max_constant = model.constant(shift_max) - max_consecutive_shifts_c = model.constant(max_consecutive_shifts) - one_c = model.constant(1) - - # OBJECTIVES: - # Objective: give employees preferred schedules (val = 2) - obj = (assignments * availability_const).sum() - - # Objective: for infeasible solutions, focus on right number of shifts for employees - target_shifts = model.constant((min_shifts + max_shifts) / 2) - shift_difference_list = [ - (assignments[e, :].sum() - target_shifts) ** 2 for e in range(num_employees) - ] - obj += add(*shift_difference_list) - - model.minimize(-obj) - - # CONSTRAINTS: - # Only schedule employees when they're available - model.add_constraint((availability_const >= assignments).all()) - - for e in range(len(employees)): - # Schedule employees for at most max_shifts - model.add_constraint(assignments[e, :].sum() <= max_shifts_constant) - - # Schedule employees for at least min_shifts - model.add_constraint(assignments[e, :].sum() >= min_shifts_constant) - - # Every shift needs shift_min and shift_max employees working - for s in range(num_shifts): - model.add_constraint(assignments[:, s].sum() <= shift_max_constant) - model.add_constraint(assignments[:, s].sum() >= shift_min_constant) - - managers_c = model.constant( - [employees.index(e) for e in employees if e[-3:] == "Mgr"] - ) - trainees_c = model.constant( - [employees.index(e) for e in employees if e[-2:] == "Tr"] - ) - - if not allow_isolated_days_off: - negthree_c = model.constant(-3) - zero_c = model.constant(0) - # Adding many small constraints greatly improves feasibility - for e in range(len(employees)): - for s1 in range(len(shifts) - 2): - s2, s3 = s1 + 1, s1 + 2 - model.add_constraint( - negthree_c * assignments[e, s2] - + assignments[e][s1] * assignments[e][s2] - + assignments[e][s1] * assignments[e][s3] - + assignments[e][s2] * assignments[e][s3] - <= zero_c - ) - - if requires_manager: - for shift in range(len(shifts)): - model.add_constraint(assignments[managers_c][:, shift].sum() >= one_c) - - # Don't exceed max_consecutive_shifts - for e in range(num_employees): - for s in range(num_shifts - max_consecutive_shifts + 1): - s_window = s + max_consecutive_shifts + 1 - model.add_constraint( - assignments[e][s : s_window + 1].sum() <= max_consecutive_shifts_c - ) - - # Trainee must work on shifts with trainer - trainers = [] - for i in trainees_c.state(): - trainer_name = employees[int(i)][:-3] - trainers.append(employees.index(trainer_name)) - trainers_c = model.constant(trainers) - - model.add_constraint((assignments[trainees_c] <= assignments[trainers_c]).all()) - - return model, assignments - - -def run_nl( - model: Model, - assignments: BinaryVariable, - sampler: LeapHybridNLSampler, - time_limit: int = 15, - solution_printer: Callable | None = None, -) -> None: - if not model.is_locked(): - model.lock() - - sampler = LeapHybridNLSampler() - sampler.sample(model, time_limit=time_limit) - if solution_printer is not None: - solution_printer(assignments) - else: - print(model.state_size()) - - -def pretty_print_solution(assignments: BinaryVariable): - result = assignments.state() - employees = list(availability.keys()) - shift_availability = list(availability.values()) - unicode_check = "*" - unicode_heart = Fore.LIGHTGREEN_EX + "@" + Style.RESET_ALL - unicode_heartbreak = Fore.LIGHTRED_EX + "/" + Style.RESET_ALL - unicode_sos = Fore.RED + "E" + Style.RESET_ALL - header = ["Employee", *shifts] - solution_table = [] - - print(f"{unicode_check} = shift assigned") - print(f"{unicode_heart} = preferred shift assigned") - print(f"{unicode_heartbreak} = preferred shift not assigned") - print(f"{unicode_sos} = unavailable shift assigned") - for i, employee in enumerate(employees): - employee_shifts = [employee] - solution_table.append(employee_shifts) - for j in range(len(shifts)): - match result[i, j], shift_availability[i][j]: - case 0, 0 | 1: - employee_shifts.append("") - case 0, 2: - employee_shifts.append(unicode_heartbreak) - case 1, 1: - employee_shifts.append(unicode_check) - case 1, 2: - employee_shifts.append(unicode_heart) - case 1, 0: - employee_shifts.append(unicode_sos) - case _: - raise Exception( - f"Case result={result[i,j]}, available={shift_availability[i][j]} not a valid case" - ) - - print(tabulate(solution_table, headers=header, tablefmt="grid")) - - -def validate_schedule( - assignments: BinaryVariable, - availability: dict[str, list[int]], - shifts: list[str], - min_shifts: int, - max_shifts: int, - shift_min: int, - shift_max: int, - requires_manager: bool, - allow_isolated_days_off: bool, - max_consecutive_shifts: int, -) -> list[str]: - result = assignments.state() - employees = list(availability.keys()) - errors = [] - errors.extend( - [ - *validate_availability(result, availability, employees, shifts), - *validate_shifts_per_employee(result, employees, min_shifts, max_shifts), - *validate_employees_per_shift(result, shifts, shift_min, shift_max), - *validate_max_consecutive_shifts(result, employees, max_consecutive_shifts), - *validate_trainee_shifts(result, employees), - ] - ) - if requires_manager: - errors.extend(validate_requires_manager(result, employees)) - if not allow_isolated_days_off: - errors.extend(validate_isolated_days_off(result, employees, shifts)) - return errors - - -def validate_availability( - results: np.ndarray, - availability: dict[str, list[int]], - employees: list[str], - shifts: list[str], -) -> list[str]: - errors = [] - for e, employee in enumerate(employees): - for s, shift in enumerate(shifts): - if results[e, s] > availability[employee][s]: - msg = ( - f"Employee {employee} scheduled for shift {shift} but not available" - ) - errors.append(msg) - return errors - - -def validate_shifts_per_employee( - results: np.ndarray, employees: list[str], min_shifts: int, max_shifts: int -) -> list[str]: - errors = [] - for e, employee in enumerate(employees): - num_shifts = results[e, :].sum() - msg = None - if num_shifts < min_shifts: - msg = f"Employee {employee} scheduled for {num_shifts} but requires at least {min_shifts}" - elif num_shifts > max_shifts: - msg = f"Employee {employee} scheduled for {num_shifts} but requires at most {max_shifts}" - if msg: - errors.append(msg) - return errors - - -def validate_employees_per_shift( - results: np.ndarray, shifts: list[str], shift_min: int, shift_max: int -) -> list[str]: - errors = [] - for s, shift in enumerate(shifts): - num_employees = results[:, s].sum() - msg = None - if num_employees < shift_min: - msg = f"Shift {shift} scheduled for {num_employees} employees but requires at least {shift_min}" - elif num_employees > shift_max: - msg = f"Shift {shift} scheduled for {num_employees} employees but requires at most {shift_max}" - if msg: - errors.append(msg) - return errors - - -def validate_requires_manager( - results: np.ndarray, - employees: list[str], -) -> list[str]: - errors = [] - employee_arr = np.asarray( - [employees.index(e) for e in employees if e[-3:] == "Mgr"] - ) - managers_per_shift = results[employee_arr].sum(axis=0) - for shift, num_managers in enumerate(managers_per_shift): - if num_managers == 0: - msg = f"Shift {shift + 1} requires at least 1 manager but 0 scheduled" - errors.append(msg) - - return errors - - -def validate_isolated_days_off( - results: np.ndarray, - employees: list[str], - shifts: list[str], -) -> list[str]: - errors = [] - isolated_pattern = np.array([1, 0, 1]) - for e, employee in enumerate(employees): - shift_triples = [results[e, i : i + 3] for i in range(results.shape[1] - 2)] - for s, shift_set in enumerate(shift_triples): - if np.equal(shift_set, isolated_pattern).all(): - msg = f"Employee {employee} has an isolated day off on shift {shifts[s+1]}" - errors.append(msg) - - return errors - - -def validate_max_consecutive_shifts( - results: np.ndarray, employees: list[str], max_consecutive_shifts: int -) -> list[str]: - errors = [] - for e, employee in enumerate(employees): - for shifts in [ - results[e, i : i + max_consecutive_shifts] - for i in range(results.shape[1] - max_consecutive_shifts) - ]: - if shifts.sum() > max_consecutive_shifts: - msg = f"Employee {employee} scheduled for more than {max_consecutive_shifts} max consecutive shifts" - errors.append(msg) - break - - return errors - - -def validate_trainee_shifts(results: np.ndarray, employees: list[str]) -> list[str]: - errors = [] - trainees = {employees.index(e): e for e in employees if e[-2:] == "Tr"} - trainers = { - employees.index(e): e for e in employees if e + "-Tr" in trainees.values() - } - for (trainee_i, trainee), (trainer_i, trainer) in zip( - trainees.items(), trainers.items() - ): - same_shifts = np.less_equal(results[trainee_i], results[trainer_i]) - for i, s in enumerate(same_shifts): - if not s: - msg = f"Trainee {trainee} scheduled on shift {i+1} without trainer {trainer}" - errors.append(msg) - - return errors - - -if __name__ == "__main__": - model, assignments = build_nl( - availability, - shifts, - min_shifts, - max_shifts, - shift_min, - shift_max, - requires_manager, - allow_isolated_days_off, - max_consecutive_shifts, - ) - - model.lock() - - time_limit = max(len(availability), len(shifts)) - - sampler: LeapHybridNLSampler = LeapHybridNLSampler() - sampler.sample(model) - - print(model.objective.state()) - - pretty_print_solution(assignments) - - errors = validate_schedule( - assignments, - availability, - shifts, - min_shifts, - max_shifts, - shift_min, - shift_max, - requires_manager, - allow_isolated_days_off, - max_consecutive_shifts, - ) - - for e in errors: - print(e) From 8d471561459ce67fba7060c74b03d4e80ab35076 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 10:35:55 -0700 Subject: [PATCH 28/49] fix shift label bug with NL error msgs --- employee_scheduling.py | 82 +++++++++++++++++------------------------- utils.py | 34 ++++++++++-------- 2 files changed, 52 insertions(+), 64 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 49f79e1..2440158 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -29,54 +29,36 @@ MSGS = { - "unavailable": ( - "Employees scheduled when unavailable", - "{employee} on {day}" - ), - "overtime": ( - "Employees with scheduled overtime", - "{employee}" - ), - "insufficient": ( - "Employees with not enough scheduled time", - "{employee}" - ), - "understaffed": ( - "Understaffed shifts", - "{day} is understaffed" - ), - "overstaffed": ( - "Overstaffed shifts", - "{day} is overstaffed" - ), - "isolated": ( - "Isolated shifts", - "{day} is an isolated day off for {employee}" - ), - "manager_issue": ( - "Shifts with no manager", - "No manager scheduled on {day}" - ), + "unavailable": ("Employees scheduled when unavailable", "{employee} on {day}"), + "overtime": ("Employees with scheduled overtime", "{employee}"), + "insufficient": ("Employees with not enough scheduled time", "{employee}"), + "understaffed": ("Understaffed shifts", "{day} is understaffed"), + "overstaffed": ("Overstaffed shifts", "{day} is overstaffed"), + "isolated": ("Isolated shifts", "{day} is an isolated day off for {employee}"), + "manager_issue": ("Shifts with no manager", "No manager scheduled on {day}"), "too_many_consecutive": ( "Employees with too many consecutive shifts", - "{employee} starting with {day}" + "{employee} starting with {day}", ), "trainee_issue": ( "Shifts with trainee scheduling issues", - "Trainee scheduling issue on {day}" + "Trainee scheduling issue on {day}", ), } def build_cqm(params: ModelParams): """Builds the ConstrainedQuadraticModel for the given scenario.""" + print(params.shifts) cqm = ConstrainedQuadraticModel() employees = list(params.availability.keys()) # Create variables: one per employee per shift - x = {(employee, shift): Binary(employee + "_" + shift) - for shift in params.shifts - for employee in employees} + x = { + (employee, shift): Binary(employee + "_" + shift) + for shift in params.shifts + for employee in employees + } # OBJECTIVES: # Objective: give employees preferred schedules (val = 2) @@ -89,18 +71,17 @@ def build_cqm(params: ModelParams): # Objective: for infeasible solutions, focus on right number of shifts for employees num_s = (params.min_shifts + params.max_shifts) / 2 for employee in employees: - obj += ( - quicksum(x[employee, shift] for shift in params.shifts) - num_s - ) ** 2 + obj += (quicksum(x[employee, shift] for shift in params.shifts) - num_s) ** 2 cqm.set_objective(obj) - # CONSTRAINTS: # Only schedule employees when they're available for employee, schedule in params.availability.items(): for i, shift in enumerate(params.shifts): if schedule[i] == 0: - cqm.add_constraint(x[employee, shift] == 0, label=f"unavailable,{employee},{shift}") + cqm.add_constraint( + x[employee, shift] == 0, label=f"unavailable,{employee},{shift}" + ) for employee in employees: # Schedule employees for at most max_shifts @@ -158,8 +139,12 @@ def build_cqm(params: ModelParams): for s in range(len(params.shifts) - params.max_consecutive_shifts): cqm.add_constraint( quicksum( - [x[employee, params.shifts[s + i]] for i in range(params.max_consecutive_shifts + 1)] - ) <= params.max_consecutive_shifts, + [ + x[employee, params.shifts[s + i]] + for i in range(params.max_consecutive_shifts + 1) + ] + ) + <= params.max_consecutive_shifts, label=f"too_many_consecutive,{employee},{params.shifts[s]}", ) @@ -167,8 +152,7 @@ def build_cqm(params: ModelParams): trainees = [employee for employee in employees if employee[-2:] == "Tr"] for shift in params.shifts: cqm.add_constraint( - x[trainees[0], shift] - - x[trainees[0], shift] * x[trainees[0][:-3], shift] + x[trainees[0], shift] - x[trainees[0], shift] * x[trainees[0][:-3], shift] == 0, label=f"trainee_issue,,{shift}", ) @@ -331,7 +315,7 @@ def run_nl( assignments: BinaryVariable, params: ModelParams, time_limit: int | None = None, - msgs: dict[str, tuple[str, str]] = MSGS + msgs: dict[str, tuple[str, str]] = MSGS, ) -> Optional[defaultdict[str, list[str]]]: """Solves the NL scheduling model and detects any errors. @@ -343,10 +327,10 @@ def run_nl( if not model.is_locked(): model.lock() - # If time limit is None, use heuristic of largest `assignments` dimension + # If time limit is None, use heuristic of largest `assignments` dimension # rounded up to 5 - if time_limit is None: - time_limit = (max_dim := max(assignments.shape())) + max_dim % 5 + # if time_limit is None: + # time_limit = (max_dim := max(assignments.shape())) + max_dim % 5 sampler = LeapHybridNLSampler() sampler.sample(model, time_limit=time_limit) @@ -358,7 +342,7 @@ def run_nl( return errors return None - -if __name__ == '__main__': - ... \ No newline at end of file + +if __name__ == "__main__": + ... diff --git a/utils.py b/utils.py index 15b755b..3cbc00c 100644 --- a/utils.py +++ b/utils.py @@ -16,7 +16,7 @@ import random import string from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field from app_configs import REQUESTED_SHIFT_ICON, UNAVAILABLE_ICON import numpy as np @@ -42,7 +42,7 @@ class ModelParams: """Convenience class for defining and passing model parameters. - Args: + Attributes: availability (dict[str, list[int]]): Employee availability for each shift, structured as follows: ``` @@ -64,6 +64,7 @@ class ModelParams: allow_isolated_days_off (bool): Whether isolated shifts off are allowed (pattern of on-off-on). max_consecutive_shifts (int): Max consecutive shifts for each employee. + shift_labels (list[str]): Day/date labels for shifts. """ availability: dict[str, list[int]] shifts: list[str] @@ -74,6 +75,10 @@ class ModelParams: requires_manager: bool allow_isolated_days_off: bool max_consecutive_shifts: int + shift_labels: list[str] = field(init=False) + + def __post_init__(self): + self.shift_labels = [f"{DAYS[i%7]} {SHIFTS[i]}" for i in range(len(self.shifts))] def get_random_string(length): @@ -177,6 +182,7 @@ def get_cols(): ]} for i, c in enumerate(SHIFTS)] ) + def get_cell_styling(cols): """Sets conditional cell styling.""" return [ @@ -388,10 +394,10 @@ def _validate_availability( key `'unavailable'`.""" msg_key, msg_template = msgs["unavailable"] for e, employee in enumerate(employees): - for s, shift in enumerate(params.shifts): + for s, day in enumerate(params.shift_labels): if results[e, s] > params.availability[employee][s]: errors[msg_key].append( - msg_template.format(employee=employee, day=shift) + msg_template.format(employee=employee, day=day) ) return errors @@ -428,14 +434,14 @@ def _validate_employees_per_shift( """Validates the number of employees per shift for the solution and updates the `errors` dictionary with any errors found. Requires the `msgs` dict to have the keys `'understaffed'` and `'overstaffed'`.""" - for s, shift in enumerate(params.shifts): + for s, day in enumerate(params.shift_labels): understaffed_key, understaffed_template = msgs["understaffed"] overstaffed_key, overstaffed_template = msgs["overstaffed"] num_employees = results[:, s].sum() if num_employees < params.shift_min: - errors[understaffed_key].append(understaffed_template.format(day=shift)) + errors[understaffed_key].append(understaffed_template.format(day=day)) elif num_employees > params.shift_max: - errors[overstaffed_key].append(overstaffed_template.format(day=shift)) + errors[overstaffed_key].append(overstaffed_template.format(day=day)) return errors @@ -456,7 +462,7 @@ def _validate_requires_manager( managers_per_shift = results[employee_arr].sum(axis=0) for shift, num_managers in enumerate(managers_per_shift): if num_managers == 0: - errors[key].append(template.format(day=params.shifts[shift])) + errors[key].append(template.format(day=params.shift_labels[shift])) return errors @@ -476,8 +482,8 @@ def _validate_isolated_days_off( shift_triples = [results[e, i : i + 3] for i in range(results.shape[1] - 2)] for s, shift_set in enumerate(shift_triples): if np.equal(shift_set, isolated_pattern).all(): - shift = params.shifts[s + 1] - errors[key].append(template.format(employee=employee, day=shift)) + day = params.shift_labels[s + 1] + errors[key].append(template.format(employee=employee, day=day)) return errors @@ -501,7 +507,7 @@ def _validate_max_consecutive_shifts( ): if shift_arr.sum() > params.max_consecutive_shifts: errors[key].append( - template.format(employee=employee, day=params.shifts[shift]) + template.format(employee=employee, day=params.shift_labels[shift]) ) break return errors @@ -522,11 +528,9 @@ def _validate_trainee_shifts( trainers = { employees.index(e): e for e in employees if e + "-Tr" in trainees.values() } - for (trainee_i, trainee), (trainer_i, trainer) in zip( - trainees.items(), trainers.items() - ): + for (trainee_i), (trainer_i) in zip(trainees.keys(), trainers.keys()): same_shifts = np.less_equal(results[trainee_i], results[trainer_i]) for i, s in enumerate(same_shifts): if not s: - errors[key].append(template.format(day=params.shifts[i])) + errors[key].append(template.format(day=params.shift_labels[i])) return errors From 3a19e5860dc853bb10370ab58a488e7f0119ee80 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 11:04:11 -0700 Subject: [PATCH 29/49] remove time limit heuristic --- employee_scheduling.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 2440158..f7ab660 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -327,11 +327,6 @@ def run_nl( if not model.is_locked(): model.lock() - # If time limit is None, use heuristic of largest `assignments` dimension - # rounded up to 5 - # if time_limit is None: - # time_limit = (max_dim := max(assignments.shape())) + max_dim % 5 - sampler = LeapHybridNLSampler() sampler.sample(model, time_limit=time_limit) errors = validate_nl_schedule(assignments, params, msgs) From 3a80f5fbd45ef6623c4cd6cd7cb76022db096bb4 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 15:49:27 -0700 Subject: [PATCH 30/49] create SolverType enum --- src/demo_enums.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/demo_enums.py diff --git a/src/demo_enums.py b/src/demo_enums.py new file mode 100644 index 0000000..fb9ae13 --- /dev/null +++ b/src/demo_enums.py @@ -0,0 +1,29 @@ +# Copyright 2024 D-Wave +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum + + +class SolverType(Enum): + """Enum class representing the solver types used in the demo.""" + + CQM = 0 + NL = 1 + + @property + def label(self): + return { + SolverType.CQM: "CQM", + SolverType.NL: "NL", + }[self] From 801eb2279c8565b25ec6af8e37882deb3587a17c Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 15:50:35 -0700 Subject: [PATCH 31/49] remove SOLVERS option from app_configs --- app_configs.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app_configs.py b/app_configs.py index 3bcf8fe..701a24c 100644 --- a/app_configs.py +++ b/app_configs.py @@ -36,11 +36,6 @@ # Sliders, buttons and option entries # ####################################### -SOLVERS = { - "CQM": "cqm", - "Nonlinear": "nl", -} - # min/max number of shifts per employee range slider (value means default) MIN_MAX_SHIFTS = { "min": 1, From 8950790ddbedfdea867480e82b37ce985ae46251 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 15:52:09 -0700 Subject: [PATCH 32/49] add `dropdown` function to app_html --- app_html.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app_html.py b/app_html.py index 1259928..25fdc9d 100644 --- a/app_html.py +++ b/app_html.py @@ -73,6 +73,29 @@ def range_slider(name: str, id: str, config: dict) -> html.Div: ) +def dropdown(label: str, id: str, options: list) -> html.Div: + """Dropdown element for option selection. + + Args: + label: The title that goes above the dropdown. + id: A unique selector for this element. + options: A list of dictionaries of labels and values. + """ + return html.Div( + className="dropdown-wrapper", + children=[ + html.Label(label), + dcc.Dropdown( + id=id, + options=options, + value=options[0]["value"], + clearable=False, + searchable=False, + ), + ], + ) + + def generate_control_card() -> html.Div: """Generates the control card for the dashboard. From 79b8610e32cd70ee71e5bb5565064c7f4bc7d8ed Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:13:01 -0700 Subject: [PATCH 33/49] refactor solver dropdown to use `dropdown` function --- app_html.py | 22 +++++++--------------- src/demo_enums.py | 2 +- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/app_html.py b/app_html.py index 25fdc9d..d8f1482 100644 --- a/app_html.py +++ b/app_html.py @@ -18,7 +18,8 @@ from dash import dcc, html from app_configs import (DESCRIPTION, EXAMPLE_SCENARIO, MAIN_HEADER, MAX_CONSECUTIVE_SHIFTS, MIN_MAX_EMPLOYEES, - MIN_MAX_SHIFTS, NUM_EMPLOYEES, REQUESTED_SHIFT_ICON, THUMBNAIL, UNAVAILABLE_ICON, SOLVERS) + MIN_MAX_SHIFTS, NUM_EMPLOYEES, REQUESTED_SHIFT_ICON, THUMBNAIL, UNAVAILABLE_ICON) +from src.demo_enums import SolverType def description_card(): @@ -104,24 +105,15 @@ def generate_control_card() -> html.Div: model, and solver. """ example_scenario = [{"label": size, "value": i} for i, size in enumerate(EXAMPLE_SCENARIO)] + solver_options = [{"label": s.label, "value": s.value} for s in SolverType] return html.Div( id="control-card", children=[ - html.Div( - children=[ - html.Label("Solver"), - dcc.Dropdown( - id="solver-select", - options=( - solver_options := [{"label": k, "value": v} - for k, v in SOLVERS.items()] - ), - value=solver_options[0]["value"], - clearable=False, - searchable=False, - ) - ] + dropdown( + "Solver", + "solver-select", + solver_options ), html.Div( children=[ diff --git a/src/demo_enums.py b/src/demo_enums.py index fb9ae13..6b498cd 100644 --- a/src/demo_enums.py +++ b/src/demo_enums.py @@ -18,7 +18,7 @@ class SolverType(Enum): """Enum class representing the solver types used in the demo.""" - CQM = 0 + CQM = 0 # Default value for application dropdown NL = 1 @property From cb3e2e3d133ce0c977aaee3a4e47ab2774dc0c5f Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:17:07 -0700 Subject: [PATCH 34/49] refactor presets dropdown to use `dropdown` function --- app_html.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/app_html.py b/app_html.py index d8f1482..f5ae9fd 100644 --- a/app_html.py +++ b/app_html.py @@ -113,19 +113,12 @@ def generate_control_card() -> html.Div: dropdown( "Solver", "solver-select", - solver_options + solver_options, ), - html.Div( - children=[ - html.Label("Scenario preset (sets sliders below)"), - dcc.Dropdown( - id="example-scenario-select", - options=example_scenario, - value=example_scenario[0]["value"], - clearable=False, - searchable=False, - ), - ] + dropdown( + "Scenario preset (sets sliders below)", + "example-scenario-select", + example_scenario ), # add sliders for employees and shifts slider( From 3ad7d4fa6506f698f7a4f56ddea71c7fda613f67 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:17:23 -0700 Subject: [PATCH 35/49] change SolverType.NL label --- src/demo_enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/demo_enums.py b/src/demo_enums.py index 6b498cd..f3f3c86 100644 --- a/src/demo_enums.py +++ b/src/demo_enums.py @@ -25,5 +25,5 @@ class SolverType(Enum): def label(self): return { SolverType.CQM: "CQM", - SolverType.NL: "NL", + SolverType.NL: "Nonlinear", }[self] From 4178948f89a9852de8943ae501b6dd584966ae73 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:29:20 -0700 Subject: [PATCH 36/49] use SolverType in app.py --- app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index c528acc..670ed3c 100644 --- a/app.py +++ b/app.py @@ -31,6 +31,7 @@ SMALL_SCENARIO, ) from app_html import errors_list, set_html +from src.demo_enums import SolverType if TYPE_CHECKING: from pandas import DataFrame @@ -324,7 +325,7 @@ def run_optimization( checklist: list[int], consecutive_shifts: int, sched_df: DataFrame, - solver: str, + solver: SolverType, ) -> tuple[DataFrame, bool, dict, list]: """Run a job on the hybrid solver when the run button is clicked.""" if run_click == 0 or ctx.triggered_id != "run-button": @@ -351,7 +352,7 @@ def run_optimization( max_consecutive_shifts=consecutive_shifts ) - if solver == "cqm": + if solver == SolverType.CQM: cqm = employee_scheduling.build_cqm(params) feasible_sampleset, errors = employee_scheduling.run_cqm(cqm) @@ -359,13 +360,13 @@ def run_optimization( sched = utils.build_schedule_from_sample(sample, employees) - elif solver == "nl": + elif solver == SolverType.NL: model, assignments = employee_scheduling.build_nl(params) errors = employee_scheduling.run_nl(model, assignments, params) sched = utils.build_schedule_from_state(assignments.state(), employees, shifts) else: - raise ValueError(f"Solver value `{solver}` is unhandled.") + raise ValueError(f"Solver value `{solver.label}` is unhandled.") return ( utils.display_schedule(sched, availability), From 40a571eaf5f3814fee18be98ab4766910f03bb92 Mon Sep 17 00:00:00 2001 From: Jameson Albers <77123028+jlalbers@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:34:27 -0700 Subject: [PATCH 37/49] fix typo in utils.py Co-authored-by: Kate Culver --- utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils.py b/utils.py index 3cbc00c..460fc40 100644 --- a/utils.py +++ b/utils.py @@ -49,7 +49,7 @@ class ModelParams: availability = { 'Employee Name': [ 0, # 0 if unavailable for shift at index i - 1, # 1 if availabile for shift at index i + 1, # 1 if available for shift at index i 2, # 2 if shift at index i is preferred ... ] From ca1358426563df642c0e76f8f8482dced725192a Mon Sep 17 00:00:00 2001 From: Jameson Albers <77123028+jlalbers@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:35:35 -0700 Subject: [PATCH 38/49] remove print debug statement in employee_scheduling.py Co-authored-by: Kate Culver --- employee_scheduling.py | 1 - 1 file changed, 1 deletion(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index f7ab660..1aa5fc0 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -49,7 +49,6 @@ def build_cqm(params: ModelParams): """Builds the ConstrainedQuadraticModel for the given scenario.""" - print(params.shifts) cqm = ConstrainedQuadraticModel() employees = list(params.availability.keys()) From 2d1893aa53d8c925af9136e4438de82061cb34e8 Mon Sep 17 00:00:00 2001 From: Jameson Albers <77123028+jlalbers@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:36:25 -0700 Subject: [PATCH 39/49] refactor key/value assignments out of loop in utils.py Co-authored-by: Kate Culver --- utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/utils.py b/utils.py index 460fc40..5fa273b 100644 --- a/utils.py +++ b/utils.py @@ -434,9 +434,10 @@ def _validate_employees_per_shift( """Validates the number of employees per shift for the solution and updates the `errors` dictionary with any errors found. Requires the `msgs` dict to have the keys `'understaffed'` and `'overstaffed'`.""" + understaffed_key, understaffed_template = msgs["understaffed"] + overstaffed_key, overstaffed_template = msgs["overstaffed"] + for s, day in enumerate(params.shift_labels): - understaffed_key, understaffed_template = msgs["understaffed"] - overstaffed_key, overstaffed_template = msgs["overstaffed"] num_employees = results[:, s].sum() if num_employees < params.shift_min: errors[understaffed_key].append(understaffed_template.format(day=day)) From 0f5edb6cc0964a245ccd7ee9755057b3bbcc72a2 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:40:54 -0700 Subject: [PATCH 40/49] use `MSGS` global variable direclty in `run_cqm` --- employee_scheduling.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 1aa5fc0..563bafd 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -159,7 +159,7 @@ def build_cqm(params: ModelParams): return cqm -def run_cqm(cqm, msgs=MSGS): +def run_cqm(cqm): """Run the provided CQM on the Leap Hybrid CQM Sampler.""" sampler = LeapHybridCQMSampler() @@ -181,7 +181,7 @@ def run_cqm(cqm, msgs=MSGS): if not sat_array[i]: key, *data = sampleset.info["constraint_labels"][i].split(",") try: - heading, error_msg = msgs[key] + heading, error_msg = MSGS[key] except KeyError: # ignore any unknown constraint labels continue From 6475aa21855d32f5905fb8e975db5de1f31f69c6 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:50:16 -0700 Subject: [PATCH 41/49] fix bug accessing SolverType.label --- app.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 670ed3c..b9b904c 100644 --- a/app.py +++ b/app.py @@ -325,7 +325,7 @@ def run_optimization( checklist: list[int], consecutive_shifts: int, sched_df: DataFrame, - solver: SolverType, + solver: int, ) -> tuple[DataFrame, bool, dict, list]: """Run a job on the hybrid solver when the run button is clicked.""" if run_click == 0 or ctx.triggered_id != "run-button": @@ -352,7 +352,9 @@ def run_optimization( max_consecutive_shifts=consecutive_shifts ) - if solver == SolverType.CQM: + solver_type = SolverType(solver) + + if solver_type == SolverType.CQM: cqm = employee_scheduling.build_cqm(params) feasible_sampleset, errors = employee_scheduling.run_cqm(cqm) @@ -360,13 +362,13 @@ def run_optimization( sched = utils.build_schedule_from_sample(sample, employees) - elif solver == SolverType.NL: + elif solver_type == SolverType.NL: model, assignments = employee_scheduling.build_nl(params) errors = employee_scheduling.run_nl(model, assignments, params) sched = utils.build_schedule_from_state(assignments.state(), employees, shifts) else: - raise ValueError(f"Solver value `{solver.label}` is unhandled.") + raise ValueError(f"Solver value `{solver_type.label}` is unhandled.") return ( utils.display_schedule(sched, availability), From c9483d5b8e1abf071bf07cab64e4851c8b8bdcfa Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Wed, 18 Sep 2024 16:50:54 -0700 Subject: [PATCH 42/49] refactor consecutive shift arrays to variable --- utils.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/utils.py b/utils.py index 5fa273b..75eb0e8 100644 --- a/utils.py +++ b/utils.py @@ -500,12 +500,11 @@ def _validate_max_consecutive_shifts( to have the key `'too_many_consecutive'`.""" key, template = msgs["too_many_consecutive"] for e, employee in enumerate(employees): - for shift, shift_arr in enumerate( - [ - results[e, i : i + params.max_consecutive_shifts] - for i in range(results.shape[1] - params.max_consecutive_shifts) - ] - ): + consecutive_shift_arrays = ( + [results[e, i : i + params.max_consecutive_shifts] + for i in range(results.shape[1] - params.max_consecutive_shifts)] + ) + for shift, shift_arr in enumerate(consecutive_shift_arrays): if shift_arr.sum() > params.max_consecutive_shifts: errors[key].append( template.format(employee=employee, day=params.shift_labels[shift]) From a5c21466959371a7a1570036ef40834cc88527ef Mon Sep 17 00:00:00 2001 From: Jameson Albers <77123028+jlalbers@users.noreply.github.com> Date: Fri, 20 Sep 2024 09:58:36 -0700 Subject: [PATCH 43/49] change '==' comparison to 'is' for readability in app.py Co-authored-by: Kate Culver --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index b9b904c..6d955d2 100644 --- a/app.py +++ b/app.py @@ -354,7 +354,7 @@ def run_optimization( solver_type = SolverType(solver) - if solver_type == SolverType.CQM: + if solver_type is SolverType.CQM: cqm = employee_scheduling.build_cqm(params) feasible_sampleset, errors = employee_scheduling.run_cqm(cqm) @@ -362,7 +362,7 @@ def run_optimization( sched = utils.build_schedule_from_sample(sample, employees) - elif solver_type == SolverType.NL: + elif solver_type is SolverType.NL: model, assignments = employee_scheduling.build_nl(params) errors = employee_scheduling.run_nl(model, assignments, params) sched = utils.build_schedule_from_state(assignments.state(), employees, shifts) From 623d03318af8eac2c019d5cccc24166c024391dc Mon Sep 17 00:00:00 2001 From: Jameson Albers <77123028+jlalbers@users.noreply.github.com> Date: Fri, 20 Sep 2024 10:16:03 -0700 Subject: [PATCH 44/49] Update employee_scheduling.py Co-authored-by: Alexander Condello --- employee_scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 563bafd..3af0f25 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -211,7 +211,7 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: tuple[Model, BinaryVariable]: the NL model and assignments decision variable """ # Create list of employees - employees = list(params.availability.keys()) + employees = list(params.availability) model = Model() # Create a binary symbol representing the assignment of employees to shifts From 2eb1a79740cd86613db922eecdfbc5a2335649d4 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 20 Sep 2024 12:18:52 -0700 Subject: [PATCH 45/49] edit implementation of ModelParams and nl validation functions --- utils.py | 88 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 38 deletions(-) diff --git a/utils.py b/utils.py index 75eb0e8..b72d823 100644 --- a/utils.py +++ b/utils.py @@ -16,7 +16,7 @@ import random import string from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from app_configs import REQUESTED_SHIFT_ICON, UNAVAILABLE_ICON import numpy as np @@ -64,8 +64,8 @@ class ModelParams: allow_isolated_days_off (bool): Whether isolated shifts off are allowed (pattern of on-off-on). max_consecutive_shifts (int): Max consecutive shifts for each employee. - shift_labels (list[str]): Day/date labels for shifts. """ + availability: dict[str, list[int]] shifts: list[str] min_shifts: int @@ -75,10 +75,6 @@ class ModelParams: requires_manager: bool allow_isolated_days_off: bool max_consecutive_shifts: int - shift_labels: list[str] = field(init=False) - - def __post_init__(self): - self.shift_labels = [f"{DAYS[i%7]} {SHIFTS[i]}" for i in range(len(self.shifts))] def get_random_string(length): @@ -319,8 +315,16 @@ def availability_to_dict(availability_list): def validate_nl_schedule( assignments: BinaryVariable, - params: ModelParams, msgs: dict[str, tuple[str, str]], + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, ) -> defaultdict[str, list[str]]: """Detect any errors in a solved NL scheduling model. @@ -365,27 +369,31 @@ def validate_nl_schedule( # Pull solution state as ndarray, employees as list result = assignments.state() - employees = list(params.availability.keys()) + employees = list(availability.keys()) + + # Convert shifts to day/date labels + shift_labels = [f"{DAYS[i%7]} {SHIFTS[i]}" for i in range(len(shifts))] errors = defaultdict(list) - _validate_availability(params, result, employees, errors, msgs) - _validate_shifts_per_employee(params, result, employees, errors, msgs) - _validate_employees_per_shift(params, result, errors, msgs) - _validate_max_consecutive_shifts(params, result, employees, errors, msgs) - _validate_trainee_shifts(params, result, employees, errors, msgs) - if params.requires_manager: - _validate_requires_manager(params, result, employees, errors, msgs) - if not params.allow_isolated_days_off: - _validate_isolated_days_off(params, result, employees, errors, msgs) + _validate_availability(result, availability, employees, shift_labels, errors, msgs) + _validate_shifts_per_employee(result, employees, min_shifts, max_shifts, errors, msgs) + _validate_employees_per_shift(result, shift_min, shift_max, shift_labels, errors, msgs) + _validate_max_consecutive_shifts(result, max_consecutive_shifts, employees, shift_labels, errors, msgs) + _validate_trainee_shifts(result, employees, shift_labels, errors, msgs) + if requires_manager: + _validate_requires_manager(result, employees, shift_labels, errors, msgs) + if not allow_isolated_days_off: + _validate_isolated_days_off(result, employees, shift_labels, errors, msgs) return errors def _validate_availability( - params: ModelParams, results: np.ndarray, + availability: dict[str, list[int]], employees: list[str], + shift_labels: list[int], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -394,8 +402,8 @@ def _validate_availability( key `'unavailable'`.""" msg_key, msg_template = msgs["unavailable"] for e, employee in enumerate(employees): - for s, day in enumerate(params.shift_labels): - if results[e, s] > params.availability[employee][s]: + for s, day in enumerate(shift_labels): + if results[e, s] > availability[employee][s]: errors[msg_key].append( msg_template.format(employee=employee, day=day) ) @@ -403,9 +411,10 @@ def _validate_availability( def _validate_shifts_per_employee( - params: ModelParams, results: np.ndarray, employees: list[str], + min_shifts: int, + max_shifts: int, errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -416,18 +425,20 @@ def _validate_shifts_per_employee( overtime_key, overtime_template = msgs["overtime"] for e, employee in enumerate(employees): num_shifts = results[e, :].sum() - if num_shifts < params.min_shifts: + if num_shifts < min_shifts: errors[insufficient_key].append( insufficient_template.format(employee=employee) ) - elif num_shifts > params.max_shifts: + elif num_shifts > max_shifts: errors[overtime_key].append(overtime_template.format(employee=employee)) return errors def _validate_employees_per_shift( - params: ModelParams, results: np.ndarray, + shift_min: int, + shift_max: int, + shift_labels: list[int], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -437,19 +448,19 @@ def _validate_employees_per_shift( understaffed_key, understaffed_template = msgs["understaffed"] overstaffed_key, overstaffed_template = msgs["overstaffed"] - for s, day in enumerate(params.shift_labels): + for s, day in enumerate(shift_labels): num_employees = results[:, s].sum() - if num_employees < params.shift_min: + if num_employees < shift_min: errors[understaffed_key].append(understaffed_template.format(day=day)) - elif num_employees > params.shift_max: + elif num_employees > shift_max: errors[overstaffed_key].append(overstaffed_template.format(day=day)) return errors def _validate_requires_manager( - params: ModelParams, results: np.ndarray, employees: list[str], + shift_labels: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -463,14 +474,14 @@ def _validate_requires_manager( managers_per_shift = results[employee_arr].sum(axis=0) for shift, num_managers in enumerate(managers_per_shift): if num_managers == 0: - errors[key].append(template.format(day=params.shift_labels[shift])) + errors[key].append(template.format(day=shift_labels[shift])) return errors def _validate_isolated_days_off( - params: ModelParams, results: np.ndarray, employees: list[str], + shift_labels: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -483,15 +494,16 @@ def _validate_isolated_days_off( shift_triples = [results[e, i : i + 3] for i in range(results.shape[1] - 2)] for s, shift_set in enumerate(shift_triples): if np.equal(shift_set, isolated_pattern).all(): - day = params.shift_labels[s + 1] + day = shift_labels[s + 1] errors[key].append(template.format(employee=employee, day=day)) return errors def _validate_max_consecutive_shifts( - params: ModelParams, results: np.ndarray, + max_consecutive_shifts: int, employees: list[str], + shift_labels: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -501,22 +513,22 @@ def _validate_max_consecutive_shifts( key, template = msgs["too_many_consecutive"] for e, employee in enumerate(employees): consecutive_shift_arrays = ( - [results[e, i : i + params.max_consecutive_shifts] - for i in range(results.shape[1] - params.max_consecutive_shifts)] + [results[e, i : i + max_consecutive_shifts] + for i in range(results.shape[1] - max_consecutive_shifts)] ) for shift, shift_arr in enumerate(consecutive_shift_arrays): - if shift_arr.sum() > params.max_consecutive_shifts: + if shift_arr.sum() > max_consecutive_shifts: errors[key].append( - template.format(employee=employee, day=params.shift_labels[shift]) + template.format(employee=employee, day=shift_labels[shift]) ) break return errors def _validate_trainee_shifts( - params: ModelParams, results: np.ndarray, employees: list[str], + shift_labels: list[str], errors: defaultdict[str, list[str]], msgs: dict[str, tuple[str, str]], ) -> defaultdict[str, list[str]]: @@ -532,5 +544,5 @@ def _validate_trainee_shifts( same_shifts = np.less_equal(results[trainee_i], results[trainer_i]) for i, s in enumerate(same_shifts): if not s: - errors[key].append(template.format(day=params.shift_labels[i])) + errors[key].append(template.format(day=shift_labels[i])) return errors From 5af20b65797fc1bedf040054e56fb234b466187b Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 20 Sep 2024 12:19:42 -0700 Subject: [PATCH 46/49] refactor functions to use individual parameters instead of ModelParams --- employee_scheduling.py | 144 +++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/employee_scheduling.py b/employee_scheduling.py index 3af0f25..59bb77d 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -25,7 +25,7 @@ from dwave.optimization.symbols import BinaryVariable from dwave.system import LeapHybridCQMSampler, LeapHybridNLSampler -from utils import DAYS, SHIFTS, ModelParams, validate_nl_schedule +from utils import DAYS, SHIFTS, validate_nl_schedule MSGS = { @@ -47,36 +47,46 @@ } -def build_cqm(params: ModelParams): +def build_cqm(#params: ModelParams + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, +): """Builds the ConstrainedQuadraticModel for the given scenario.""" cqm = ConstrainedQuadraticModel() - employees = list(params.availability.keys()) + employees = list(availability.keys()) # Create variables: one per employee per shift x = { (employee, shift): Binary(employee + "_" + shift) - for shift in params.shifts + for shift in shifts for employee in employees } # OBJECTIVES: # Objective: give employees preferred schedules (val = 2) obj = BinaryQuadraticModel(vartype="BINARY") - for employee, schedule in params.availability.items(): - for i, shift in enumerate(params.shifts): + for employee, schedule in availability.items(): + for i, shift in enumerate(shifts): if schedule[i] == 2: obj += -x[employee, shift] # Objective: for infeasible solutions, focus on right number of shifts for employees - num_s = (params.min_shifts + params.max_shifts) / 2 + num_s = (min_shifts + max_shifts) / 2 for employee in employees: - obj += (quicksum(x[employee, shift] for shift in params.shifts) - num_s) ** 2 + obj += (quicksum(x[employee, shift] for shift in shifts) - num_s) ** 2 cqm.set_objective(obj) # CONSTRAINTS: # Only schedule employees when they're available - for employee, schedule in params.availability.items(): - for i, shift in enumerate(params.shifts): + for employee, schedule in availability.items(): + for i, shift in enumerate(shifts): if schedule[i] == 0: cqm.add_constraint( x[employee, shift] == 0, label=f"unavailable,{employee},{shift}" @@ -85,35 +95,35 @@ def build_cqm(params: ModelParams): for employee in employees: # Schedule employees for at most max_shifts cqm.add_constraint( - quicksum(x[employee, shift] for shift in params.shifts) - <= params.max_shifts, + quicksum(x[employee, shift] for shift in shifts) + <= max_shifts, label=f"overtime,{employee},", ) # Schedule employees for at least min_shifts cqm.add_constraint( - quicksum(x[employee, shift] for shift in params.shifts) - >= params.min_shifts, + quicksum(x[employee, shift] for shift in shifts) + >= min_shifts, label=f"insufficient,{employee},", ) # Every shift needs shift_min and shift_max employees working - for shift in params.shifts: + for shift in shifts: cqm.add_constraint( - sum(x[employee, shift] for employee in employees) >= params.shift_min, + sum(x[employee, shift] for employee in employees) >= shift_min, label=f"understaffed,,{shift}", ) cqm.add_constraint( - sum(x[employee, shift] for employee in employees) <= params.shift_max, + sum(x[employee, shift] for employee in employees) <= shift_max, label=f"overstaffed,,{shift}", ) # Days off must be consecutive - if not params.allow_isolated_days_off: + if not allow_isolated_days_off: # middle range shifts - pattern 101 penalized - for i, prev_shift in enumerate(params.shifts[:-2]): - shift = params.shifts[i + 1] - next_shift = params.shifts[i + 2] + for i, prev_shift in enumerate(shifts[:-2]): + shift = shifts[i + 1] + next_shift = shifts[i + 2] for employee in employees: cqm.add_constraint( -3 * x[employee, shift] @@ -125,9 +135,9 @@ def build_cqm(params: ModelParams): ) # Require a manager on every shift - if params.requires_manager: + if requires_manager: managers = [employee for employee in employees if employee[-3:] == "Mgr"] - for shift in params.shifts: + for shift in shifts: cqm.add_constraint( quicksum(x[manager, shift] for manager in managers) == 1, label=f"manager_issue,,{shift}", @@ -135,21 +145,21 @@ def build_cqm(params: ModelParams): # Don't exceed max_consecutive_shifts for employee in employees: - for s in range(len(params.shifts) - params.max_consecutive_shifts): + for s in range(len(shifts) - max_consecutive_shifts): cqm.add_constraint( quicksum( [ - x[employee, params.shifts[s + i]] - for i in range(params.max_consecutive_shifts + 1) + x[employee, shifts[s + i]] + for i in range(max_consecutive_shifts + 1) ] ) - <= params.max_consecutive_shifts, - label=f"too_many_consecutive,{employee},{params.shifts[s]}", + <= max_consecutive_shifts, + label=f"too_many_consecutive,{employee},{shifts[s]}", ) # Trainee must work on shifts with trainer trainees = [employee for employee in employees if employee[-2:] == "Tr"] - for shift in params.shifts: + for shift in shifts: cqm.add_constraint( x[trainees[0], shift] - x[trainees[0], shift] * x[trainees[0][:-3], shift] == 0, @@ -201,37 +211,55 @@ def run_cqm(cqm): return feasible_sampleset, None -def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: +def build_nl( + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, +) -> tuple[Model, BinaryVariable]: """Builds an employee scheduling nonlinear model. Args: - params (ModelParams): model parameters + availability (dict[str, list[int]]): Employee availability. + shifts (list[str]): Shift labels. + min_shifts (int): Minimum shifts per employee. + max_shifts (int): Maximum shifts per employee. + shift_min (int): Minimum employees per shift. + shift_max (int): Maximum employees per shift. + requires_manager (bool): Whether to require exactly one manager on every shift. + allow_isolated_days_off (bool): Whether to allow isolated days off. + max_consecutive_shifts (int): Maximum consecutive shifts per employee. Returns: tuple[Model, BinaryVariable]: the NL model and assignments decision variable """ # Create list of employees - employees = list(params.availability) + employees = list(availability) model = Model() # Create a binary symbol representing the assignment of employees to shifts # i.e. assignments[employee][shift] = 1 if assigned, else 0 num_employees = len(employees) - num_shifts = len(params.shifts) + num_shifts = len(shifts) assignments = model.binary((num_employees, num_shifts)) # Create availability constant - availability_list = [params.availability[employee] for employee in employees] + availability_list = [availability[employee] for employee in employees] for i, sublist in enumerate(availability_list): availability_list[i] = [a if a != 2 else 100 for a in sublist] availability_const = model.constant(availability_list) # Initialize model constants - min_shifts_constant = model.constant(params.min_shifts) - max_shifts_constant = model.constant(params.max_shifts) - shift_min_constant = model.constant(params.shift_min) - shift_max_constant = model.constant(params.shift_max) - max_consecutive_shifts_c = model.constant(params.max_consecutive_shifts) + min_shifts_constant = model.constant(min_shifts) + max_shifts_constant = model.constant(max_shifts) + shift_min_constant = model.constant(shift_min) + shift_max_constant = model.constant(shift_max) + max_consecutive_shifts_c = model.constant(max_consecutive_shifts) one_c = model.constant(1) # OBJECTIVES: @@ -239,7 +267,7 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: obj = (assignments * availability_const).sum() # Objective: for infeasible solutions, focus on right number of shifts for employees - target_shifts = model.constant((params.min_shifts + params.max_shifts) / 2) + target_shifts = model.constant((min_shifts + max_shifts) / 2) shift_difference_list = [ (assignments[e, :].sum() - target_shifts) ** 2 for e in range(num_employees) ] @@ -270,12 +298,12 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: [employees.index(e) for e in employees if e[-2:] == "Tr"] ) - if not params.allow_isolated_days_off: + if not allow_isolated_days_off: negthree_c = model.constant(-3) zero_c = model.constant(0) # Adding many small constraints greatly improves feasibility for e in range(len(employees)): - for s1 in range(len(params.shifts) - 2): + for s1 in range(len(shifts) - 2): s2, s3 = s1 + 1, s1 + 2 model.add_constraint( negthree_c * assignments[e, s2] @@ -285,14 +313,14 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: <= zero_c ) - if params.requires_manager: - for shift in range(len(params.shifts)): + if requires_manager: + for shift in range(len(shifts)): model.add_constraint(assignments[managers_c][:, shift].sum() == one_c) # Don't exceed max_consecutive_shifts for e in range(num_employees): - for s in range(num_shifts - params.max_consecutive_shifts + 1): - s_window = s + params.max_consecutive_shifts + 1 + for s in range(num_shifts - max_consecutive_shifts + 1): + s_window = s + max_consecutive_shifts + 1 model.add_constraint( assignments[e][s : s_window + 1].sum() <= max_consecutive_shifts_c ) @@ -312,7 +340,15 @@ def build_nl(params: ModelParams) -> tuple[Model, BinaryVariable]: def run_nl( model: Model, assignments: BinaryVariable, - params: ModelParams, + availability: dict[str, list[int]], + shifts: list[str], + min_shifts: int, + max_shifts: int, + shift_min: int, + shift_max: int, + requires_manager: bool, + allow_isolated_days_off: bool, + max_consecutive_shifts: int, time_limit: int | None = None, msgs: dict[str, tuple[str, str]] = MSGS, ) -> Optional[defaultdict[str, list[str]]]: @@ -328,7 +364,19 @@ def run_nl( sampler = LeapHybridNLSampler() sampler.sample(model, time_limit=time_limit) - errors = validate_nl_schedule(assignments, params, msgs) + errors = validate_nl_schedule( + assignments, + msgs, + availability, + shifts, + min_shifts, + max_shifts, + shift_min, + shift_max, + requires_manager, + allow_isolated_days_off, + max_consecutive_shifts, + ) # Return errors if any error message list is populated for error_list in errors.values(): From 7536567aa831b4e7540e59bff1dc729681d15765 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 20 Sep 2024 12:23:35 -0700 Subject: [PATCH 47/49] unpack `params` into function arguments --- app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 6d955d2..6541eb1 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ from __future__ import annotations import multiprocess +from dataclasses import asdict from typing import TYPE_CHECKING, Any import diskcache @@ -355,7 +356,7 @@ def run_optimization( solver_type = SolverType(solver) if solver_type is SolverType.CQM: - cqm = employee_scheduling.build_cqm(params) + cqm = employee_scheduling.build_cqm(**asdict(params)) feasible_sampleset, errors = employee_scheduling.run_cqm(cqm) sample = feasible_sampleset.first.sample @@ -363,8 +364,8 @@ def run_optimization( sched = utils.build_schedule_from_sample(sample, employees) elif solver_type is SolverType.NL: - model, assignments = employee_scheduling.build_nl(params) - errors = employee_scheduling.run_nl(model, assignments, params) + model, assignments = employee_scheduling.build_nl(**asdict(params)) + errors = employee_scheduling.run_nl(model, assignments, **asdict(params)) sched = utils.build_schedule_from_state(assignments.state(), employees, shifts) else: From 1a024bc925d1ddc851a99d0243e660d8dc72be80 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 20 Sep 2024 12:32:25 -0700 Subject: [PATCH 48/49] add comment to clarify changing of assignment value from 2 to 100 in `build_nl` --- employee_scheduling.py | 1 + 1 file changed, 1 insertion(+) diff --git a/employee_scheduling.py b/employee_scheduling.py index 59bb77d..7fbeab6 100644 --- a/employee_scheduling.py +++ b/employee_scheduling.py @@ -250,6 +250,7 @@ def build_nl( # Create availability constant availability_list = [availability[employee] for employee in employees] + # Boost objective value of preferred shifts from 2 to 100 for i, sublist in enumerate(availability_list): availability_list[i] = [a if a != 2 else 100 for a in sublist] availability_const = model.constant(availability_list) From 95aeacdae809b9bcc9ab8d2a3bcad57ae69abee0 Mon Sep 17 00:00:00 2001 From: Jameson Albers Date: Fri, 20 Sep 2024 13:15:11 -0700 Subject: [PATCH 49/49] update tests to use `asdict` with ModelParams --- tests/test_inputs.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 3488324..36ce825 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import unittest +from dataclasses import asdict from dash import dash_table from numpy import asarray @@ -47,14 +48,14 @@ def test_initial_sched(self): # Check that CQM created has the right number of variables def test_cqm(self): - cqm = employee_scheduling.build_cqm(self.test_params) + cqm = employee_scheduling.build_cqm(**asdict(self.test_params)) self.assertEqual(len(cqm.variables), self.num_employees * len(self.shifts)) # Check that NL assignments variable is the correct shape def test_nl(self): - _, assignments = employee_scheduling.build_nl(self.test_params) + _, assignments = employee_scheduling.build_nl(**asdict(self.test_params)) self.assertEqual(assignments.shape(), (self.num_employees, len(self.shifts))) @@ -84,7 +85,7 @@ def test_samples_cqm(self): max_consecutive_shifts=6 ) - cqm = employee_scheduling.build_cqm(test_params) + cqm = employee_scheduling.build_cqm(**asdict(test_params)) feasible_sample = { "A-Mgr_1": 0.0, @@ -180,7 +181,7 @@ def test_states_nl(self): max_consecutive_shifts=6 ) - model, assignments = employee_scheduling.build_nl(test_params) + model, assignments = employee_scheduling.build_nl(**asdict(test_params)) if not model.is_locked(): model.lock() @@ -274,6 +275,7 @@ def test_build_from_sample(self): def test_build_from_state(self): employees = ["A-Mgr", "B-Mgr", "C", "D", "E", "E-Tr"] + shifts = [str(i+1) for i in range(14)] # Make every employee available for every shift availability = { @@ -286,15 +288,17 @@ def test_build_from_state(self): } state = asarray([ - [0, 0, 1, 1, 1], - [1, 1, 0, 0, 0], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1] + # Give managers alternating shifts + [i % 2 for i in range(14)], + [(i+1) % 2 for i in range(14)], + [1 for _ in range(14)], + [1 for _ in range(14)], + [1 for _ in range(14)], + [1 for _ in range(14)], ]) disp_datatable = utils.display_schedule( - utils.build_schedule_from_state(state, employees), + utils.build_schedule_from_state(state, employees, shifts), availability )