diff --git a/src/job_shop_formulation_cqm.py b/src/job_shop_formulation_cqm.py new file mode 100755 index 0000000..6c76431 --- /dev/null +++ b/src/job_shop_formulation_cqm.py @@ -0,0 +1,329 @@ +import sys +import time + +import warnings + +from dimod import Binary, ConstrainedQuadraticModel, Integer +from dwave.system import LeapHybridCQMSampler +import utils.mip_solver as mip_solver +from model_data import JobShopData +from utils.utils import print_cqm_stats + +sys.path.append("./src") + + +class JobShopSchedulingCQM: + """Builds and solves a Job Shop Scheduling problem using CQM. + + Args: + data (JobShopData): The data for the job shop scheduling + max_makespan (int, optional): The maximum makespan allowed for the schedule. + + Attributes: + data (JobShopData): The data for the job shop scheduling + cqm (ConstrainedQuadraticModel): The CQM model + x (dict): A dictionary of the integer variables for the start time of using machine i for job j + y (dict): A dictionary of the binary variables which equals to 1 if job j precedes job k on machine i + makespan (Integer): The makespan variable + best_sample (dict): The best sample found by the CQM solver + solution (dict): The solution to the problem + completion_time (int): The completion time of the schedule + max_makespan (int): The maximum makespan allowed for the schedule + solver_name: name of the solver either CQM or MIP + verbose (bool, optional): Whether to print verbose output. Defaults to False. + + """ + + def __init__( + self, data: JobShopData, max_makespan: int, solver_name: str, verbose: bool = False): + self.data = data + self.cqm = None + self.x = {} + self.y = {} + self.makespan = {} + self.best_sample = {} + self.solution = {} + self.completion_time = 0 + self.max_makespan = max_makespan + self.solver_name = solver_name + self.verbose = verbose + self.ordered_tasks = data.get_ordered_tasks() + self.job_resources = {j:[] for j in data.jobs} + for _, machine, job in self.ordered_tasks: + self.job_resources[job].append(machine) + + def make(self): + """ + This function initializes the problem variables, objectives, and constraints. + + Modifies: + self.model: prepares CQM or MIP model for solving + """ + + if self.solver_name == "MIP": + # Cannot use quadratic constraints with MIP solver + allow_quadratic_constraints = False + elif self.solver_name == "CQM": + allow_quadratic_constraints = True + else: + raise ValueError("solver_name not compatible with model") + + self.define_cqm_model() + self.define_variables(self.data) + self.add_precedence_constraints(self.data) + if allow_quadratic_constraints: + self.add_quadratic_overlap_constraint(self.data) + else: + self.add_disjunctive_constraints(self.data) + self.add_makespan_constraint(self.data) + self.define_objective_function() + + if self.verbose: + print_cqm_stats(self.cqm) + + def solve(self, time_limit: int, profile: str=None): + """Solve the model using either the CQM solver or MIP solver. + Args: + time_limit (int): time limit in second + profile (str): The profile variable to pass to the Sampler. Defaults to None. + + Modifies: + self.model: The sampler will solve the model, which modifies + the model in place. + """ + + if self.solver_name == "MIP": + self.call_mip_solver(time_limit=time_limit) + else: + self.call_cqm_solver(time_limit=time_limit, data=self.data, profile=profile) + + + def define_cqm_model(self) -> None: + """Define CQM model.""" + self.cqm = ConstrainedQuadraticModel() + + def define_variables(self, data: JobShopData) -> None: + """Define CQM variables. + + Args: + data: a JobShopData data class + + Modifies: + self.x: a dictionary of integer variables for the start time of using machine i for job j + self.y: a dictionary of binary variables which equals to 1 if job j precedes job k on machine i + self.makespan: an integer variable for the makespan of the schedule + """ + # Define make span as an integer variable + self.makespan = Integer("makespan", lower_bound=0, upper_bound=self.max_makespan) + + # Define integer variable for start time of using machine i for job j + self.x = {} + for job in data.jobs: + for resource in self.job_resources[job]: + task = data.get_resource_job_tasks(job=job, resource=resource) + lb, ub = data.get_task_time_bounds(task, self.max_makespan) + self.x[(job, resource)] = Integer( + "x{}_{}".format(job, resource), lower_bound=lb, upper_bound=ub + ) + + # Add binary variable which equals to 1 if job j precedes job k on + # machine i + self.y = { + (j, k, i): Binary("y{}_{}_{}".format(j, k, i)) + for j in data.jobs + for k in data.jobs + for i in data.resources + } + + def define_objective_function(self) -> None: + """Define objective function, which is to minimize + the makespan of the schedule. + + Modifies: + self.cqm: adds the objective function to the CQM model + """ + self.cqm.set_objective(self.makespan) + + def add_precedence_constraints(self, data: JobShopData) -> None: + """Precedence constraints ensures that all operations of a job are + executed in the given order. + + Args: + data: a JobShopData data class + + Modifies: + self.cqm: adds precedence constraints to the CQM model + """ + for job in data.jobs: # job + for prev_task, curr_task in zip( + data.job_tasks[job][:-1], data.job_tasks[job][1:] + ): + machine_curr = curr_task.resource + machine_prev = prev_task.resource + self.cqm.add_constraint( + self.x[(job, machine_curr)] - self.x[(job, machine_prev)] >= prev_task.duration, + label="pj{}_m{}".format(job, machine_curr), + ) + + def add_quadratic_overlap_constraint(self, data: JobShopData) -> None: + """Add quadratic constraints to ensure that no two jobs can be scheduled + on the same machine at the same time. + + Args: + data: a JobShopData data class + + Modifies: + self.cqm: adds quadratic constraints to the CQM model + """ + for j in data.jobs: + for k in data.jobs: + if j < k: + for i in data.resources: + if not (i in self.job_resources[k] and i in self.job_resources[j]): + continue + task_k = data.get_resource_job_tasks(job=k, resource=i) + task_j = data.get_resource_job_tasks(job=j, resource=i) + + if task_k.duration > 0 and task_j.duration > 0: + self.cqm.add_constraint( + self.x[(j, i)] + - self.x[(k, i)] + + (task_k.duration - task_j.duration) * self.y[(j, k, i)] + + 2 * self.y[(j, k, i)] * (self.x[(k, i)] - self.x[(j, i)]) + >= task_k.duration, + label="OneJobj{}_j{}_m{}".format(j, k, i), + ) + + def add_disjunctive_constraints(self, data: JobShopData) -> None: + """This function adds the disjunctive constraints the prevent two jobs + from being scheduled on the same machine at the same time. This is a + non-quadratic alternative to the quadratic overlap constraint. + + Args: + data (JobShopData): The data for the job shop scheduling + + Modifies: + self.cqm: adds disjunctive constraints to the CQM model + """ + V = self.max_makespan + for j in data.jobs: + for k in data.jobs: + if j < k: + for i in data.resources: + task_k = data.get_resource_job_tasks(job=k, resource=i) + self.cqm.add_constraint( + self.x[(j, i)] + - self.x[(k, i)] + - task_k.duration + + self.y[(j, k, i)] * V + >= 0, + label="disjunction1{}_j{}_m{}".format(j, k, i), + ) + + task_j = data.get_resource_job_tasks(job=j, resource=i) + self.cqm.add_constraint( + self.x[(k, i)] + - self.x[(j, i)] + - task_j.duration + + (1 - self.y[(j, k, i)]) * V + >= 0, + label="disjunction2{}_j{}_m{}".format(j, k, i), + ) + + def add_makespan_constraint(self, data: JobShopData) -> None: + """Ensures that the make span is at least the largest completion time of + the last operation of all jobs. + + Args: + data: a JobShopData data class + + Modifies: + self.cqm: adds the makespan constraint to the CQM model + """ + for job in data.jobs: + last_job_task = data.job_tasks[job][-1] + last_machine = last_job_task.resource + self.cqm.add_constraint( + self.makespan - self.x[(job, last_machine)] >= last_job_task.duration, + label="makespan_ctr{}".format(job), + ) + + def call_cqm_solver(self, time_limit: int, data: JobShopData, profile: str) -> None: + """Calls CQM solver. + + Args: + time_limit (int): time limit in second + data (JobShopData): a JobShopData data class + profile (str): The profile variable to pass to the Sampler. Defaults to None. + See documentation at + https://docs.dwavequantum.com/en/latest/ocean/api_ref_cloud/generated/dwave.cloud.config.load_config.html + + Modifies: + self.feasible_sampleset: a SampleSet object containing the feasible solutions + self.best_sample: the best sample found by the CQM solver + self.solution: the solution to the problem + self.completion_time: the completion time of the schedule + """ + sampler = LeapHybridCQMSampler(profile=profile) + min_time_limit = sampler.min_time_limit(self.cqm) + if time_limit is not None: + time_limit = max(min_time_limit, time_limit) + raw_sampleset = sampler.sample_cqm(self.cqm, time_limit=time_limit, label="Job Shop Demo") + self.feasible_sampleset = raw_sampleset.filter(lambda d: d.is_feasible) + num_feasible = len(self.feasible_sampleset) + if num_feasible > 0: + best_samples = self.feasible_sampleset.truncate(min(10, num_feasible)) + else: + warnings.warn("Warning: CQM did not find feasible solution") + best_samples = raw_sampleset.truncate(10) + + print(" \n" + "=" * 30 + "BEST SAMPLE SET" + "=" * 30) + print(best_samples) + self.best_sample = best_samples.first.sample + + def call_mip_solver(self, time_limit: int = 100): + """This function calls the MIP solver and returns the solution + + Args: + time_limit (int, optional): The maximum amount of time to + allow the MIP solver to before returning. Defaults to 100. + + Modifies: + self.best_sample: the best feasoble sampel obtained form solver + """ + solver = mip_solver.MIPCQMSolver() + raw_sampleset = solver.sample_cqm(cqm=self.cqm, time_limit=time_limit) + if len(raw_sampleset) == 0: + warnings.warn("Warning: MIP did not find feasible solution") + return + + best_samples = raw_sampleset.truncate(10) + print(" \n" + "=" * 30 + "BEST SAMPLE SET" + "=" * 30) + print(best_samples) + self.best_sample = best_samples.first.sample + + def compute_results(self) -> None: + """Extracts the results from a solved model and prints to console. + """ + if self.solver_name == "CQM": + self.solution = { + (j, i): ( + # self.data.get_resource_job_tasks(job=j, resource=i), + self.best_sample[self.x[(j, i)].variables[0]], + self.data.get_resource_job_tasks(job=j, resource=i).duration, + ) + for j in self.data.jobs for i in self.job_resources[j] + } + + elif self.solver_name == "MIP": + for var, val in self.best_sample.items(): + if var.startswith("x"): + job, machine = var[1:].split("_") + job = int(job) + machine = int(machine) + task = self.data.get_resource_job_tasks(job=job, resource=machine) + self.solution[(job, machine)] = val, task.duration + else: + raise ValueError("Solver") + + self.completion_time = self.best_sample["makespan"] diff --git a/src/job_shop_formulation_nl.py b/src/job_shop_formulation_nl.py new file mode 100755 index 0000000..4b937c5 --- /dev/null +++ b/src/job_shop_formulation_nl.py @@ -0,0 +1,222 @@ +from dwave.system import LeapHybridNLSampler +from dwave.optimization import Model +import numpy as np +from dwave.optimization import Model +from dwave.optimization.mathematical import maximum, put +from model_data import JobShopData +import warnings + + +class JobShopSchedulingNL: + + """Builds and solves a Job Shop Scheduling problem using LeapHybridNLSampler. + + Args: + data (JobShopData): The data for the job shop scheduling + max_makespan (int, optional): The maximum makespan allowed for the schedule. + verbose (bool, optional): Whether to print verbose output. Defaults to False. + """ + def __init__(self, data: JobShopData, max_makespan: int, verbose: bool=False): + + self.machine_dict = {m: i for i, m in enumerate(data.resources)} + self.job_dicts = {j: i for i, j in enumerate(data.jobs)} + + self.n_machines = len(self.machine_dict) + self.n_jobs = len(self.job_dicts) + + self.machines = list(self.machine_dict.values()) + self.jobs = list(self.job_dicts.values()) + + self.max_makespan = max_makespan + ordered_tasks = data.get_ordered_tasks() + self.ordered_tasks = [(d, self.machine_dict[m], self.job_dicts[j]) for (d, m , j) in ordered_tasks] + + self.n_tasks = len(self.ordered_tasks) + self.task_durations = list([v[0] for v in self.ordered_tasks]) + self.task_machines = list([v[1] for v in self.ordered_tasks]) + self.task_jobs = list([v[2] for v in self.ordered_tasks]) + + # Mapping between jobs and tasks. + self.job_tasks = {j:[] for j in self.jobs} + for i, j in enumerate(self.task_jobs): + self.job_tasks[j].append(i) + self.job_process_order = {j:i for i, j in enumerate(self.jobs)} + self.verbose = verbose + self.solution = {} + self.completion_time = 0 + + def define_variables(self) -> None: + """ + This function initializes the problem variables + + Initializes: + self.nl_model: The NL model that will be solved is initialised here + self.task_order: decision variable that determines the order in + which tasks are performed + """ + self.nl_model = Model() + self.task_order = self.nl_model.list(self.n_tasks) + if self.verbose: + print(f"Model variables defined, total number of nodes {self.nl_model.num_nodes()}") + + + def add_precedence_constraints(self) -> None: + """defines a constraint which ensures that each task within the same job + are completed in the correct order + + Modifies: + self.nl_model: adds constraints to ensure precedence constraints are + respected in task ordering + """ + + """Precedence constraints ensures that all operations of a job are + executed in the given order. + + Modifies: + self.nl_model: adds precedence constraints to the nl model + """ + + succ = [] + pred = [] + for job_j in self.jobs: + job_tasks = self.job_tasks[job_j] + for task_t in range(1, len(job_tasks)): + succ.append(job_tasks[task_t]) + pred.append(job_tasks[task_t - 1]) + + successor_task_ids = self.nl_model.constant(succ) + predecessor_task_ids = self.nl_model.constant(pred) + self.nl_model.add_constraint((self.start_times[successor_task_ids] >= self.finish_times[predecessor_task_ids]).all()) + + if self.verbose: + print(f"Added precedence constraint, total number of nodes {self.nl_model.num_nodes()}") + + def build_schedule(self) -> None: + """ + builds a schedule from the the model decision variables the start time of each task + given in self.task_order is used to build the scehdule considering machine availability + and job sequencing constraints. + A task is assigned when + - Its assigned machine is free. + - The previous task in the same job (if any) has completed. + + Modifies: + self.start_times: the start time of each task, ordered by self.task_order + self.finish_times: the finish time of each task, ordered according to + self.task_order and calculated as the sum of start time, duration and cleaning time + + """ + + machine_available_time = self.nl_model.constant([0] * self.n_machines) + job_last_task_end_time = self.nl_model.constant([0] * self.n_jobs) + task_end_times = self.nl_model.constant([0] * self.n_tasks) + task_start_times = self.nl_model.constant([0] * self.n_tasks) + job_ids = self.nl_model.constant(self.task_jobs) + machine_ids = self.nl_model.constant(self.task_machines) + durations = self.nl_model.constant(self.task_durations) + + for i in range(self.n_tasks): + task_idx = self.task_order[i] + machine_id = machine_ids[task_idx] + job_id = job_ids[task_idx] + duration = durations[task_idx] + + # Calculate the time that assgined machine for this taks is avilable + machine_time = machine_available_time[machine_id] + + # Calculate the time that the current job for the the current taks is avilable + job_time = job_last_task_end_time[job_id] + + # Start time is calculated when bothe machine and job is ready. + start_time = maximum(machine_time, job_time) + end_time = start_time + duration + + task_start_times = put(task_start_times, task_idx.reshape((1,)), start_time.reshape((1,))) + task_end_times = put(task_end_times, task_idx.reshape((1,)), end_time.reshape((1,))) + + # Update machine and job availability + machine_available_time = put(machine_available_time, machine_id.reshape((1,)), end_time.reshape((1,))) + job_last_task_end_time = put(job_last_task_end_time, job_id.reshape((1,)), end_time.reshape((1,))) + + self.start_times = task_start_times + self.finish_times = task_end_times + if self.verbose: + print(f"Tasks start and finish times computed, total number of nodes {self.nl_model.num_nodes()}") + + def make(self) -> None: + """ + This function initializes the problem variables, objectives, and constraints. + + Modifies: + self.nl_model: prepares NL model for solving + """ + + self.define_variables() + + # Build start and finish times + self.build_schedule() + + # Define recedence constraints + self.add_precedence_constraints() + + # Set the objectives + self.define_objectives() + + self.nl_model.lock() + + if self.verbose: + print(f"Finished building NL model, total number of nodes {self.nl_model.num_nodes()}") + + def define_objectives(self) -> None: + """this function sets the objective of the NL model + """ + self.obj_makespan = self.finish_times.max() + self.nl_model.minimize(self.obj_makespan) + + def solve(self, time_limit: int, profile: str = None) -> None: + """Solve the model using the LeapHybridNLSampler. + Args: + time_limit (int): time limit in second + profile (str): The profile variable to pass to the Sampler. Defaults to None. + See documentation at + https://docs.dwavequantum.com/en/latest/ocean/api_ref_cloud/generated/dwave.cloud.config.load_config.html + + Modifies: + self.nl_model: The sampler will solve the model, which modifies + the model in place. + """ + sampler = LeapHybridNLSampler(profile=profile) + sampler.sample(self.nl_model, time_limit=time_limit, label="jobshop NL") + + if self.verbose: + print(f"NL model upload to Leap completed.") + # Ensure that sampling is done + self.nl_model.states.resolve() + + def compute_results(self) -> None: + """Extracts the results from a solved model and prints to console. + """ + + solution_index = 0 + order = [int(task) for task in self.task_order.state(solution_index)] + + # Check if the solution is feasible + solution_feasibible = all(c.state(solution_index) for c in self.nl_model.iter_constraints()) + job_dicts_rev = {j: i for i, j in self.job_dicts.items()} + machine_dict_rev = {m: i for i, m in self.machine_dict.items()} + + if solution_feasibible: + makespan = self.obj_makespan.state(solution_index) + start_times_schedule = self.start_times.state(solution_index) + + self.solution = {} + for task_t in order: + job = self.task_jobs[task_t] + machine = self.task_machines[task_t] + start = start_times_schedule[task_t] + duration = self.task_durations[task_t] + self.solution[(job_dicts_rev[job], machine_dict_rev[machine])] = (start, duration) + + self.completion_time = makespan + else: + warnings.warn("Warning: NL did not find feasible solution") diff --git a/src/job_shop_scheduler.py b/src/job_shop_scheduler.py index cf5b025..59690fd 100644 --- a/src/job_shop_scheduler.py +++ b/src/job_shop_scheduler.py @@ -6,20 +6,18 @@ import argparse import sys -import warnings from time import time import pandas as pd -from dimod import Binary, ConstrainedQuadraticModel, Integer -from dwave.system import LeapHybridCQMSampler from tabulate import tabulate +from job_shop_formulation_cqm import JobShopSchedulingCQM +from job_shop_formulation_nl import JobShopSchedulingNL sys.path.append("./src") -import utils.mip_solver as mip_solver import utils.plot_schedule as job_plotter from model_data import JobShopData from utils.greedy import GreedyJobShop -from utils.utils import print_cqm_stats, write_solution_to_file +from utils.utils import write_solution_to_file, is_valid_schedule def generate_greedy_makespan(job_data: JobShopData, num_samples: int = 100) -> int: @@ -44,289 +42,31 @@ def generate_greedy_makespan(job_data: JobShopData, num_samples: int = 100) -> i return best_greedy -class JobShopSchedulingCQM: - """Builds and solves a Job Shop Scheduling problem using CQM. - - Args: - model_data (JobShopData): The data for the job shop scheduling - max_makespan (int, optional): The maximum makespan allowed for the schedule. - If None, the makespan will be set to a value that is greedy_mulitiplier - times the makespan found by the greedy algorithm. Defaults to None. - greedy_multiplier (float, optional): The multiplier to apply to the greedy makespan, - to get the upperbound on the makespan. Defaults to 1.4. - - Attributes: - model_data (JobShopData): The data for the job shop scheduling - cqm (ConstrainedQuadraticModel): The CQM model - x (dict): A dictionary of the integer variables for the start time of using machine i for job j - y (dict): A dictionary of the binary variables which equals to 1 if job j precedes job k on machine i - makespan (Integer): The makespan variable - best_sample (dict): The best sample found by the CQM solver - solution (dict): The solution to the problem - completion_time (int): The completion time of the schedule - max_makespan (int): The maximum makespan allowed for the schedule - - """ - - def __init__( - self, model_data: JobShopData, max_makespan: int = None, greedy_multiplier: float = 1.4 - ): - self.model_data = model_data - self.cqm = None - self.x = {} - self.y = {} - self.makespan = {} - self.best_sample = {} - self.solution = {} - self.completion_time = 0 - self.max_makespan = max_makespan - if self.max_makespan is None: - self.max_makespan = generate_greedy_makespan(model_data) * greedy_multiplier - - def define_cqm_model(self) -> None: - """Define CQM model.""" - self.cqm = ConstrainedQuadraticModel() - - def define_variables(self, model_data: JobShopData) -> None: - """Define CQM variables. - - Args: - model_data: a JobShopData data class - - Modifies: - self.x: a dictionary of integer variables for the start time of using machine i for job j - self.y: a dictionary of binary variables which equals to 1 if job j precedes job k on machine i - self.makespan: an integer variable for the makespan of the schedule - """ - # Define make span as an integer variable - self.makespan = Integer("makespan", lower_bound=0, upper_bound=self.max_makespan) - - # Define integer variable for start time of using machine i for job j - self.x = {} - for job in model_data.jobs: - for resource in model_data.resources: - task = model_data.get_resource_job_tasks(job=job, resource=resource) - lb, ub = model_data.get_task_time_bounds(task, self.max_makespan) - self.x[(job, resource)] = Integer( - "x{}_{}".format(job, resource), lower_bound=lb, upper_bound=ub - ) - - # Add binary variable which equals to 1 if job j precedes job k on - # machine i - self.y = { - (j, k, i): Binary("y{}_{}_{}".format(j, k, i)) - for j in model_data.jobs - for k in model_data.jobs - for i in model_data.resources - } - - def define_objective_function(self) -> None: - """Define objective function, which is to minimize - the makespan of the schedule. - - Modifies: - self.cqm: adds the objective function to the CQM model - """ - self.cqm.set_objective(self.makespan) - - def add_precedence_constraints(self, model_data: JobShopData) -> None: - """Precedence constraints ensures that all operations of a job are - executed in the given order. - - Args: - model_data: a JobShopData data class - - Modifies: - self.cqm: adds precedence constraints to the CQM model - """ - for job in model_data.jobs: # job - for prev_task, curr_task in zip( - model_data.job_tasks[job][:-1], model_data.job_tasks[job][1:] - ): - machine_curr = curr_task.resource - machine_prev = prev_task.resource - self.cqm.add_constraint( - self.x[(job, machine_curr)] - self.x[(job, machine_prev)] >= prev_task.duration, - label="pj{}_m{}".format(job, machine_curr), - ) - - def add_quadratic_overlap_constraint(self, model_data: JobShopData) -> None: - """Add quadratic constraints to ensure that no two jobs can be scheduled - on the same machine at the same time. - - Args: - model_data: a JobShopData data class - - Modifies: - self.cqm: adds quadratic constraints to the CQM model - """ - for j in model_data.jobs: - for k in model_data.jobs: - if j < k: - for i in model_data.resources: - task_k = model_data.get_resource_job_tasks(job=k, resource=i) - task_j = model_data.get_resource_job_tasks(job=j, resource=i) - - if task_k.duration > 0 and task_j.duration > 0: - self.cqm.add_constraint( - self.x[(j, i)] - - self.x[(k, i)] - + (task_k.duration - task_j.duration) * self.y[(j, k, i)] - + 2 * self.y[(j, k, i)] * (self.x[(k, i)] - self.x[(j, i)]) - >= task_k.duration, - label="OneJobj{}_j{}_m{}".format(j, k, i), - ) - - def add_disjunctive_constraints(self, model_data: JobShopData) -> None: - """This function adds the disjunctive constraints the prevent two jobs - from being scheduled on the same machine at the same time. This is a - non-quadratic alternative to the quadratic overlap constraint. - - Args: - model_data (JobShopData): The data for the job shop scheduling - - Modifies: - self.cqm: adds disjunctive constraints to the CQM model - """ - V = self.max_makespan - for j in model_data.jobs: - for k in model_data.jobs: - if j < k: - for i in model_data.resources: - task_k = model_data.get_resource_job_tasks(job=k, resource=i) - self.cqm.add_constraint( - self.x[(j, i)] - - self.x[(k, i)] - - task_k.duration - + self.y[(j, k, i)] * V - >= 0, - label="disjunction1{}_j{}_m{}".format(j, k, i), - ) - - task_j = model_data.get_resource_job_tasks(job=j, resource=i) - self.cqm.add_constraint( - self.x[(k, i)] - - self.x[(j, i)] - - task_j.duration - + (1 - self.y[(j, k, i)]) * V - >= 0, - label="disjunction2{}_j{}_m{}".format(j, k, i), - ) - - def add_makespan_constraint(self, model_data: JobShopData) -> None: - """Ensures that the make span is at least the largest completion time of - the last operation of all jobs. - - Args: - model_data: a JobShopData data class - - Modifies: - self.cqm: adds the makespan constraint to the CQM model - """ - for job in model_data.jobs: - last_job_task = model_data.job_tasks[job][-1] - last_machine = last_job_task.resource - self.cqm.add_constraint( - self.makespan - self.x[(job, last_machine)] >= last_job_task.duration, - label="makespan_ctr{}".format(job), - ) - - def call_cqm_solver(self, time_limit: int, model_data: JobShopData, profile: str) -> None: - """Calls CQM solver. - - Args: - time_limit (int): time limit in second - model_data (JobShopData): a JobShopData data class - profile (str): The profile variable to pass to the Sampler. Defaults to None. - See documentation at - https://docs.dwavequantum.com/en/latest/ocean/api_ref_cloud/generated/dwave.cloud.config.load_config.html - - Modifies: - self.feasible_sampleset: a SampleSet object containing the feasible solutions - self.best_sample: the best sample found by the CQM solver - self.solution: the solution to the problem - self.completion_time: the completion time of the schedule - """ - sampler = LeapHybridCQMSampler(profile=profile) - min_time_limit = sampler.min_time_limit(self.cqm) - if time_limit is not None: - time_limit = max(min_time_limit, time_limit) - raw_sampleset = sampler.sample_cqm(self.cqm, time_limit=time_limit, label="Job Shop Demo") - self.feasible_sampleset = raw_sampleset.filter(lambda d: d.is_feasible) - num_feasible = len(self.feasible_sampleset) - if num_feasible > 0: - best_samples = self.feasible_sampleset.truncate(min(10, num_feasible)) - else: - warnings.warn("Warning: CQM did not find feasible solution") - best_samples = raw_sampleset.truncate(10) - - print(" \n" + "=" * 30 + "BEST SAMPLE SET" + "=" * 30) - print(best_samples) - - self.best_sample = best_samples.first.sample - - self.solution = { - (j, i): ( - model_data.get_resource_job_tasks(job=j, resource=i), - self.best_sample[self.x[(j, i)].variables[0]], - model_data.get_resource_job_tasks(job=j, resource=i).duration, - ) - for i in model_data.resources - for j in model_data.jobs - } - - self.completion_time = self.best_sample["makespan"] - - def call_mip_solver(self, time_limit: int = 100): - """This function calls the MIP solver and returns the solution - - Args: - time_limit (int, optional): The maximum amount of time to - allow the MIP solver to before returning. Defaults to 100. - - Modifies: - self.solution: the solution to the problem - """ - solver = mip_solver.MIPCQMSolver() - sol = solver.sample_cqm(cqm=self.cqm, time_limit=time_limit) - self.solution = {} - if len(sol) == 0: - warnings.warn("Warning: MIP did not find feasible solution") - return - best_sol = sol.first.sample - - for var, val in best_sol.items(): - - if var.startswith("x"): - job, machine = var[1:].split("_") - task = self.model_data.get_resource_job_tasks(job=job, resource=machine) - self.solution[(job, machine)] = task, val, task.duration - - def solution_as_dataframe(self) -> pd.DataFrame: +def solution_as_dataframe(solution) -> pd.DataFrame: """This function returns the solution as a pandas DataFrame - + Args: + solution: The solution to the problem Returns: pd.DataFrame: A pandas DataFrame containing the solution """ + df_rows = [] - for (j, i), (task, start, dur) in self.solution.items(): - df_rows.append([j, task, start, start + dur, i]) - df = pd.DataFrame(df_rows, columns=["Job", "Task", "Start", "Finish", "Resource"]) + for (j, i), (start, dur) in solution.items(): + df_rows.append([j, start, start + dur, i]) + df = pd.DataFrame(df_rows, columns=["Job", "Start", "Finish", "Resource"]) return df def run_shop_scheduler( job_data: JobShopData, solver_time_limit: int = 60, - use_mip_solver: bool = False, verbose: bool = False, - allow_quadratic_constraints: bool = True, out_sol_file: str = None, out_plot_file: str = None, profile: str = None, max_makespan: int = None, greedy_multiplier: float = 1.4, -) -> pd.DataFrame: + solver_name: str = "NL") -> pd.DataFrame: """This function runs the job shop scheduler on the given data. Args: @@ -334,51 +74,48 @@ def run_shop_scheduler( scheduling problem. solver_time_limit (int, optional): Upperbound on how long the schedule can be; leave empty to auto-calculate an appropriate value. Defaults to None. - use_mip_solver (bool, optional): Whether to use the MIP solver instead of the CQM solver. - Defaults to False. verbose (bool, optional): Whether to print verbose output. Defaults to False. - allow_quadratic_constraints (bool, optional): Whether to allow quadratic constraints. - Defaults to True. out_sol_file (str, optional): Path to the output solution file. Defaults to None. out_plot_file (str, optional): Path to the output plot file. Defaults to None. profile (str, optional): The profile variable to pass to the Sampler. Defaults to None. max_makespan (int, optional): Upperbound on how long the schedule can be; leave empty to auto-calculate an appropriate value. Defaults to None. + If None, the makespan will be set to a value that is greedy_mulitiplier + times the makespan found by the greedy algorithm. greedy_multiplier (float, optional): The multiplier to apply to the greedy makespan, to get the upperbound on the makespan. Defaults to 1.4. + solver_name: name of the solver either CQM, NL or MIP Returns: pd.DataFrame: A DataFrame that has the following columns: Task, Start, Finish, and Resource. """ - if allow_quadratic_constraints and use_mip_solver: - raise ValueError("Cannot use quadratic constraints with MIP solver") - model_building_start = time() - model = JobShopSchedulingCQM( - model_data=job_data, max_makespan=max_makespan, greedy_multiplier=greedy_multiplier - ) - model.define_cqm_model() - model.define_variables(job_data) - model.add_precedence_constraints(job_data) - if allow_quadratic_constraints: - model.add_quadratic_overlap_constraint(job_data) + ordered_tasks = job_data.get_ordered_tasks() + + if max_makespan is None: + best_greedy_span = generate_greedy_makespan(job_data) + max_makespan = int(best_greedy_span * greedy_multiplier) + + if solver_name == "NL": + model = JobShopSchedulingNL(data=job_data, max_makespan=max_makespan, verbose=verbose) + elif solver_name in ["CQM", "MIP"]: + model = JobShopSchedulingCQM(data=job_data, max_makespan=max_makespan, solver_name=solver_name, verbose=verbose) else: - model.add_disjunctive_constraints(job_data) - model.add_makespan_constraint(job_data) - model.define_objective_function() + raise ValueError(f'solver_name not defined') - if verbose: - print_cqm_stats(model.cqm) + model_building_start = time() + model.make() model_building_time = time() - model_building_start + solver_start_time = time() - if use_mip_solver: - sol = model.call_mip_solver(time_limit=solver_time_limit) - else: - model.call_cqm_solver(time_limit=solver_time_limit, model_data=job_data, profile=profile) - sol = model.best_sample + model.solve(time_limit=solver_time_limit, profile=profile) solver_time = time() - solver_start_time + model.compute_results() + if not is_valid_schedule(model.solution, ordered_tasks): + print("Solution is not valid") + if verbose: print(" \n" + "=" * 55 + "SOLUTION RESULTS" + "=" * 55) print( @@ -387,13 +124,15 @@ def run_shop_scheduler( [ "Completion Time", "Max Make-Span", + "best_greedy_span", "Model Building Time (s)", "Solver Call Time (s)", "Total Runtime (s)", ], [ model.completion_time, - model.max_makespan, + max_makespan, + best_greedy_span, int(model_building_time), int(solver_time), int(solver_time + model_building_time), @@ -411,7 +150,7 @@ def run_shop_scheduler( if out_plot_file is not None: job_plotter.plot_solution(job_data, model.solution, out_plot_file) - df = model.solution_as_dataframe() + df = solution_as_dataframe(model.solution) return df @@ -429,7 +168,7 @@ def run_shop_scheduler( "--instance", type=str, help="path to the input instance file; ", - default="input/instance5_5.txt", + default="input/instance3_3.txt", ) parser.add_argument("-tl", "--time_limit", type=int, help="time limit in seconds", default=10) @@ -451,18 +190,16 @@ def run_shop_scheduler( ) parser.add_argument( - "-m", - "--use_mip_solver", - action="store_true", - help="Whether to use the MIP solver instead of the CQM solver", - ) + "-s", + "--solver_name", + type=str, + help="Define name of the solver, NL, CQM or MIP", - parser.add_argument( - "-v", "--verbose", action="store_true", default=True, help="Whether to print verbose output" + default="NL" ) parser.add_argument( - "-q", "--allow_quad", action="store_true", help="Whether to allow quadratic constraints" + "-v", "--verbose", action="store_true", default=True, help="Whether to print verbose output" ) parser.add_argument( @@ -487,10 +224,9 @@ def run_shop_scheduler( time_limit = args.time_limit out_plot_file = args.output_plot out_sol_file = args.output_solution - allow_quadratic_constraints = args.allow_quad max_makespan = args.max_makespan profile = args.profile - use_mip_solver = args.use_mip_solver + solver_name = args.solver_name verbose = args.verbose job_data = JobShopData() @@ -500,10 +236,9 @@ def run_shop_scheduler( job_data, time_limit, verbose=verbose, - use_mip_solver=use_mip_solver, - allow_quadratic_constraints=allow_quadratic_constraints, profile=profile, max_makespan=max_makespan, out_sol_file=out_sol_file, out_plot_file=out_plot_file, + solver_name=solver_name ) diff --git a/src/model_data.py b/src/model_data.py index 30cb306..d03831b 100644 --- a/src/model_data.py +++ b/src/model_data.py @@ -73,6 +73,15 @@ def job_tasks(self) -> dict: """ return self._job_tasks + @property + def job_resources(self) -> dict: + ordered_tasks = self.get_ordered_tasks() + job_resources = {j:[] for j in self.jobs} + for d, m, j in ordered_tasks: + job_resources[j].append(m) + return job_resources + + def get_tasks(self) -> Iterable[Task]: """Returns the tasks in the data. @@ -81,6 +90,16 @@ def get_tasks(self) -> Iterable[Task]: """ return [task for job_tasks in self._job_tasks.values() for task in job_tasks] + def get_ordered_tasks(self) -> tuple[Iterable[tuple]]: + ordered_tasks= [] + for j, val in self.job_tasks.items(): + for v in val: + assert v.job == j + ordered_tasks.append((v.duration, v.resource, v.job)) + + return ordered_tasks + + def get_last_tasks(self) -> Iterable[Task]: """Returns the last task in each job. @@ -318,7 +337,7 @@ def load_from_dict(self, jobs: dict, resource_names: list = None) -> None: resource_name = resource_mapping[resource] else: resource_name = resource - self.add_task(Task(str(job), duration=duration, resource=resource_name)) + self.add_task(Task(job, duration=duration, resource=resource_name)) def load_from_file(self, filename: str, resource_names: list = None) -> None: """Loads data from a file. diff --git a/src/utils/plot_schedule.py b/src/utils/plot_schedule.py index 851333c..7b3fd88 100644 --- a/src/utils/plot_schedule.py +++ b/src/utils/plot_schedule.py @@ -23,11 +23,11 @@ def plot_solution(job_data, solution: dict, location: str = None) -> tuple: """ job_start_time = defaultdict(list) processing_time = defaultdict(list) + job_resources = job_data.job_resources for j in job_data.jobs: - job_start_time[j] = [solution[(j, i)][1] for i in job_data.resources] + job_start_time[j] = [solution[(j, i)][0] if i in job_resources[j] else np.nan for i in job_data.resources] processing_time[j] = [ - job_data.get_resource_job_tasks(i, j).duration for i in job_data.resources - ] + job_data.get_resource_job_tasks(i, j).duration if i in job_resources[j] else np.nan for i in job_data.resources] if location is not None: plot_schedule_core(job_start_time, processing_time, location) return job_start_time, processing_time diff --git a/src/utils/utils.py b/src/utils/utils.py index a36c856..6ce7b8c 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -116,9 +116,15 @@ def write_solution_to_file( header.extend(["task", "start", "dur"]) job_sol = {} + + ordered_tasks = model_data.get_ordered_tasks() + job_resources = {j:[] for j in model_data.jobs} + for d, m, j in ordered_tasks: + job_resources[j].append(m) + for j in model_data.jobs: job_sol[j] = [j] - for i in model_data.resources: + for i in job_resources[j]: job_sol[j].extend(list(solution[j, i])) with open(solution_file_path, "w") as f: @@ -183,3 +189,68 @@ def read_taillard_instance(instance_path: str) -> dict: assert len(job_dict[0]) == num_machines return job_dict + + +def is_valid_schedule(solution , job_operations) -> bool: + """ Validates a job shop scheduling solution against resource availability and job precedence constraints. + + Args: + solution : dict + A dictionary mapping (job_id, resource_id) tuples to (start_time, duration) tuples. + Example: {(0, 1): (0, 3), (0, 2): (3, 4), (1, 1): (3, 2), ...} + + job_operations : list of dict + A list of operations, where each operation is a dictionary with: + - 'job': job ID + - 'resource': resource (machine) ID + - 'duration': duration of the operation + The order of operations in the list defines the execution order for each job. + + Returns: + bool + True if the schedule is valid (i.e., no resource conflicts and job precedence is respected), + False otherwise. Prints debug information if a constraint is violated. + + Validity Conditions: + ------------------- + 1. No two operations on the same resource may overlap in time. + 2. Job Precedence Constraint: For each job, operations must be performed in the given order, + meaning the start time of an operation must not precede the end time of its predecessor. + """ + + # Step 1: Build job_routes (ordered tasks per job) + job_routes = defaultdict(list) + for op in job_operations: + job_routes[op[2]].append((op[1], op[0])) + + # Step 2: Check resource (machine) conflicts + resource_intervals = defaultdict(list) + for (job, resource), (start, duration) in solution.items(): + if duration == 0: + continue + resource_intervals[resource].append((start, start + duration, job)) + + for resource, intervals in resource_intervals.items(): + intervals.sort() + for i in range(len(intervals) - 1): + end1 = intervals[i][1] + start2 = intervals[i+1][0] + if end1 > start2: + print(f"Conflict on resource {resource} between jobs {intervals[i][2]} and {intervals[i+1][2]}") + return False + + # Step 3: Check job precedence + for job, task_sequence in job_routes.items(): + for i in range(len(task_sequence) - 1): + r1, _ = task_sequence[i] + r2, _ = task_sequence[i + 1] + if (job, r1) not in solution or (job, r2) not in solution: + print(f"Missing schedule for job {job} on resource {r1} or {r2}") + return False + start1, dur1 = solution[(job, r1)] + start2, _ = solution[(job, r2)] + if start1 + dur1 > start2: + print(f"Job {job} precedence violated: task on {r1} ends at {start1 + dur1}, but task on {r2} starts at {start2}") + return False + + return True