diff --git a/.gitignore b/.gitignore deleted file mode 100644 index fd20fdd..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ - -*.pyc diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/Job_Shop_Scheduling_Benchmark_Environments_and_Instances.iml b/.idea/Job_Shop_Scheduling_Benchmark_Environments_and_Instances.iml deleted file mode 100644 index bab880c..0000000 --- a/.idea/Job_Shop_Scheduling_Benchmark_Environments_and_Instances.iml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index a8dd5f1..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,156 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 66c8820..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index c33840b..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index cf63073..d7066ad 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,16 @@ We aim to include a wide range of solution methods capable of solving machine sc -| Solution methods | Job Shop | Flow Show | Flexible Job Shop | SDST | Assembly | Online Arrivals | +| Solution methods | Job Shop (JSP) | Flow Show (FSP) | Flexible Job Shop (FJSP) | FJSP SDST | FAJSP | Online (F)JSP | | :---: | :---:| :---: | :---: | :---: | :---: | :---: | -| Load Balancing Heuristics | ✓ | ✓ | ✓ | ✓ | ✓ | | | Dispatching Rules | ✓ | ✓ | ✓ | | ✓ | ✓* | -| Genetic Algorithm | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Load Balancing Heuristics | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Genetic Algorithm | ✓ | ✓ | ✓ | ✓ | ✓ | | +| MILP | ✓ | ✓ | ✓ | ✓ | | | +| CP-SAT | ✓ | ✓ | ✓ | | | | | FJSP-DRL | ✓ | ✓ | ✓ | | | | -*Capable of online arrivals of FJSP problems +*Capable of online arrivals of FJSP problems 🔜 We have a few DRL-based solutions in the pipeline, which will be published here upon completion. @@ -37,4 +39,3 @@ Robbert Reijnen, Kjell van Straaten, Zaharah Bukhsh, and Yingqian Zhang (2023) [ primaryClass={cs.AI} } ``` -🔜 Supporting paper and corresponding reference diff --git a/configs/FJSP_DRL.toml b/configs/FJSP_DRL.toml index b849946..06e6a46 100644 --- a/configs/FJSP_DRL.toml +++ b/configs/FJSP_DRL.toml @@ -1,16 +1,16 @@ [env_parameters] -num_jobs = 10 # Number of jobs in the environment -num_mas = 5 # Number of machine agents -batch_size = 20 # Batch size for training -ope_feat_dim = 6 # Dimension of operation features -ma_feat_dim = 3 # Dimension of machine agent features -valid_batch_size = 5 # Batch size for validation -show_mode = "print" # Mode for displaying information (e.g., "print") +num_jobs = 10 # Number of jobs in the environment +num_mas = 5 # Number of machine agents +batch_size = 20 # Batch size for training +ope_feat_dim = 6 # Dimension of operation features +ma_feat_dim = 3 # Dimension of machine agent features +valid_batch_size = 5 # Batch size for validation +device = "cpu" # Device for training ("cpu" or "cuda") [model_parameters] in_size_ma = 3 # Input size for machine agent out_size_ma = 8 # Output size for machine agent -in_size_ope = 6 # Input size for operation +in_size_ope = 6 # Input size for operation out_size_ope = 8 # Output size for operation hidden_size_ope = 128 # Hidden size for operation model num_heads = [1, 1] # Number of attention heads @@ -20,6 +20,7 @@ n_latent_critic = 64 # Number of latent units for critic n_hidden_actor = 3 # Number of hidden layers for actor n_hidden_critic = 3 # Number of hidden layers for critic action_dim = 1 # Dimension of action space +device = "cpu" # Device for training ("cpu" or "cuda") [train_parameters] lr = 0.0002 # Learning rate @@ -33,18 +34,20 @@ entropy_coeff = 0.01 # Coefficient for entropy loss max_iterations = 1000 # Maximum number of iterations save_timestep = 20 # Timestep for saving model checkpoints update_timestep = 1 # Timestep for model updates -viz = true # Visualize the training process --> when True: first launch local server with: python -m visdom.server -viz_name = 'training_visualisation' # Name of visualization env minibatch_size = 512 # Mini-batch size for training -parallel_iter = 3 # Number of parallel iterations -device = "cpu" # Device for training ("cpu" or "cuda") +parallel_iter = 20 # Number of parallel iterations +viz = false # Visualize the training process --> when true: first launch local server with: python -m visdom.server (and set to: online) +viz_name = 'training_visualisation' # Name of visualization env validation_folder = "/fjsp/song/dev/1005/" # Folder for validation data +device = "cpu" # Device for training ("cpu" or "cuda") # Configuration for test parameters [test_parameters] -problem_instance = "/fjsp/1_Brandimarte/Mk02.fjs" # Problem instance for testing -trained_policy = "./solutions/FJSP_DRL/models/song_10_5.pt" # Load pretrained policy -sample = false # Sampling flag for testing -num_sample = 5 # Number of samples for testing (nr ) -device = "cpu" # Device for testing ("cpu" or "cuda") \ No newline at end of file +seed = 1 +problem_instance = "/fjsp/1_Brandimarte/Mk02.fjs" # Problem instance for testing +trained_policy = "/solution_methods/FJSP_DRL/save/train_20240314_192906/song_10_5.pt" # Load pretrained policy +sample = false # Sampling flag for testing +num_sample = 1 # Number of samples for testing (nr ) +plotting = true # plot instance representation and ganttchart +device = "cpu" # Device for testing ("cpu" or "cuda") \ No newline at end of file diff --git a/configs/milp.toml b/configs/milp.toml index 5b5ecf8..2809508 100644 --- a/configs/milp.toml +++ b/configs/milp.toml @@ -1,5 +1,5 @@ [instance] -problem_instance = "/fjsp/2_Hurink/edata/abz5.fjs" # configure problem instance to be scheduled +problem_instance = "/jsp/adams/abz5" # configure problem instance to be scheduled [solver] -time_limit = 3600 # time limit for the gurobi solver, in seconds \ No newline at end of file +time_limit = 64800 # time limit for the gurobi solver, in seconds \ No newline at end of file diff --git a/configs/or_tools.toml b/configs/or_tools.toml new file mode 100644 index 0000000..3146e39 --- /dev/null +++ b/configs/or_tools.toml @@ -0,0 +1,5 @@ +[instance] +problem_instance = "/fjsp/2_Hurink/edata/abz5.fjs" # configure problem instance to be scheduled + +[solver] +time_limit = 3600 # time limit for the OR-tools CP-SAT solver, in seconds \ No newline at end of file diff --git a/data_parsers/parser_fajsp.py b/data_parsers/parser_fajsp.py index e582977..c9e8645 100644 --- a/data_parsers/parser_fajsp.py +++ b/data_parsers/parser_fajsp.py @@ -1,8 +1,8 @@ -from pathlib import Path import re +from pathlib import Path -from scheduling_environment.machine import Machine from scheduling_environment.job import Job +from scheduling_environment.machine import Machine from scheduling_environment.operation import Operation diff --git a/data_parsers/parser_fjsp.py b/data_parsers/parser_fjsp.py index 1a796f1..b0f02f7 100644 --- a/data_parsers/parser_fjsp.py +++ b/data_parsers/parser_fjsp.py @@ -1,8 +1,8 @@ -from pathlib import Path import re +from pathlib import Path -from scheduling_environment.machine import Machine from scheduling_environment.job import Job +from scheduling_environment.machine import Machine from scheduling_environment.operation import Operation diff --git a/data_parsers/parser_fjsp_sdst.py b/data_parsers/parser_fjsp_sdst.py index b1d1ee3..8472526 100644 --- a/data_parsers/parser_fjsp_sdst.py +++ b/data_parsers/parser_fjsp_sdst.py @@ -1,8 +1,8 @@ from pathlib import Path -from scheduling_environment.operation import Operation -from scheduling_environment.machine import Machine from scheduling_environment.job import Job +from scheduling_environment.machine import Machine +from scheduling_environment.operation import Operation def parse(JobShop, instance, from_absolute_path=False): diff --git a/data_parsers/parser_jsp_fsp.py b/data_parsers/parser_jsp_fsp.py index 5b208cb..361d6a9 100644 --- a/data_parsers/parser_jsp_fsp.py +++ b/data_parsers/parser_jsp_fsp.py @@ -1,9 +1,9 @@ -from pathlib import Path import re +from pathlib import Path -from scheduling_environment.operation import Operation -from scheduling_environment.machine import Machine from scheduling_environment.job import Job +from scheduling_environment.machine import Machine +from scheduling_environment.operation import Operation def parse(JobShop, instance, from_absolute_path=False): diff --git a/plotting/drawer.py b/plotting/drawer.py index ec61f4b..3063084 100644 --- a/plotting/drawer.py +++ b/plotting/drawer.py @@ -1,12 +1,14 @@ import copy -import random import os -import networkx as nx +import random from statistics import mean -from scheduling_environment.jobShop import JobShop -import numpy as np -import matplotlib.pyplot as plt + import matplotlib.colors as mcolors +import matplotlib.pyplot as plt +import networkx as nx +import numpy as np + +from scheduling_environment.jobShop import JobShop def create_colormap(): diff --git a/requirements.txt b/requirements.txt index cf369a4..50adec4 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/run_FJSP_DRL.py b/run_FJSP_DRL.py index d56cdbd..538ca96 100644 --- a/run_FJSP_DRL.py +++ b/run_FJSP_DRL.py @@ -1,33 +1,16 @@ -# GITHUB REPO: https://github.com/songwenas12/fjsp-drl - -# Code based on the paper: -# "Flexible Job Shop Scheduling via Graph Neural Network and Deep Reinforcement Learning" -# by Wen Song, Xinyang Chen, Qiqiang Li and Zhiguang Cao -# Presented in IEEE Transactions on Industrial Informatics, 2023. -# Paper URL: https://ieeexplore.ieee.org/document/9826438 - import argparse -import copy import logging +import os +import time -import gymnasium as gym -import pynvml import torch -from solutions.FJSP_DRL import PPO_model -from solutions.FJSP_DRL.env import FJSPEnv -from solutions.FJSP_DRL.load_data import nums_detec -from solutions.helper_functions import load_parameters from plotting.drawer import draw_gantt_chart - -logging.basicConfig(level=logging.INFO) +from solution_methods.FJSP_DRL.env_test import FJSPEnv_test +from solution_methods.FJSP_DRL.PPO import HGNNScheduler +from solution_methods.helper_functions import load_job_shop_env, load_parameters PARAM_FILE = "configs/FJSP_DRL.toml" -DEFAULT_RESULTS_ROOT = "./results/single_runs" - -import pkg_resources -print(pkg_resources.get_distribution("gym").version) - def initialize_device(parameters: dict) -> torch.device: @@ -37,112 +20,63 @@ def initialize_device(parameters: dict) -> torch.device: return torch.device(device_str) -def schedule(env: FJSPEnv, model: PPO_model.PPO, memories, flag_sample=False): - """ - Schedules the provided environment using the given model and memories. +def run_method(**parameters): + # Extract parameters + device = initialize_device(parameters) + model_parameters = parameters["model_parameters"] + test_parameters = parameters["test_parameters"] - Parameters: - - env: The environment to be scheduled. - - model: The model to be used for scheduling. - - memories: Memories to be used with the model. - - flag_sample: A flag to determine whether sampling should be performed. + # Configure default device + torch.set_default_tensor_type('torch.cuda.FloatTensor' if device.type == 'cuda' else 'torch.FloatTensor') + if device.type == 'cuda': + torch.cuda.set_device(device) - Returns: - - A deep copy of the makespan batch from the environment. - """ + # Load trained policy + trained_policy = os.getcwd() + test_parameters['trained_policy'] + if trained_policy.endswith('.pt'): + if device.type == 'cuda': + policy = torch.load(trained_policy) + else: + policy = torch.load(trained_policy, map_location='cpu') - # Initialize the environment state and completion signals - state = env.state - dones = env.done_batch - done = False + model_parameters["actor_in_dim"] = model_parameters["out_size_ma"] * 2 + model_parameters["out_size_ope"] * 2 + model_parameters["critic_in_dim"] = model_parameters["out_size_ma"] + model_parameters["out_size_ope"] - # Iterate until all tasks are complete - while not done: - # Predict the next action without accumulating gradients - with torch.no_grad(): - actions = model.policy_old.act(state, memories, dones, flag_sample=flag_sample, flag_train=False) + hgnn_model = HGNNScheduler(model_parameters).to(device) + print('\nloading saved model:', trained_policy) + hgnn_model.load_state_dict(policy) - # Update the environment state based on the predicted action - state, _, dones = env.step(actions) - done = dones.all() + # Configure environment and load instance + instance_path = test_parameters['problem_instance'] + JSMEnv = load_job_shop_env(instance_path) + env_test = FJSPEnv_test(JSMEnv, test_parameters) - # Draw gantt chart - for ix, sim_env in enumerate(env.simulation_envs): - draw_gantt_chart(sim_env) + # Get state and completion signal + state = env_test.state + done = False + last_time = time.time() - # Check the validity of the produced Gantt chart (only feasible for FJSP) - if not env.validate_gantt()[0]: - print("Scheduling Error!") + # Generate schedule for instance + while ~done: + with torch.no_grad(): + actions = hgnn_model.act(state, [], done, flag_train=False, flag_sample=test_parameters['sample']) + state, _, done = env_test.step(actions) - return copy.deepcopy(env.makespan_batch) + print("spend_time:", time.time() - last_time) + print("makespan(s):", env_test.JSP_instance.makespan) + if test_parameters['plotting']: + draw_gantt_chart(env_test.JSP_instance) -def main(param_file: str): - # # Initialize NVML library - pynvml.nvmlInit() - # Load parameters +def main(param_file=PARAM_FILE): try: parameters = load_parameters(param_file) except FileNotFoundError: logging.error(f"Parameter file {param_file} not found.") return - device = initialize_device(parameters) - logging.info(f"Using device {device}") - # Configure PyTorch's default device - torch.set_default_tensor_type('torch.cuda.FloatTensor' if device.type == 'cuda' else 'torch.FloatTensor') - if device.type == 'cuda': - torch.cuda.set_device(device) - # Extract parameters - env_parameters = parameters["env_parameters"] - model_parameters = parameters["model_parameters"] - train_parameters = parameters["train_parameters"] - test_parameters = parameters["test_parameters"] - - batch_size = test_parameters["num_sample"] if test_parameters["sample"] else 1 - env_parameters["batch_size"] = batch_size - - model_parameters["actor_in_dim"] = model_parameters["out_size_ma"] * 2 + model_parameters["out_size_ope"] * 2 - model_parameters["critic_in_dim"] = model_parameters["out_size_ma"] + model_parameters["out_size_ope"] - - instance_path = "./data/{0}".format(test_parameters["problem_instance"]) - - # Assign device to parameters - env_parameters["device"] = device - model_parameters["device"] = device - - # Initialize model and environment - model = PPO_model.PPO(model_parameters, train_parameters) - - # Load trained policy - trained_policy = test_parameters['trained_policy'] - if trained_policy.endswith('.pt'): - if device.type == 'cuda': - policy = torch.load(trained_policy) - else: - policy = torch.load(trained_policy, map_location='cpu') - print('\nloading checkpoint:', trained_policy) - model.policy.load_state_dict(policy) - model.policy_old.load_state_dict(policy) - - # Load instance (to configure DRL env) - with open(instance_path) as file_object: - line = file_object.readlines() - num_jobs, num_machines, _ = nums_detec(line) - - env_parameters["num_jobs"] = num_jobs - env_parameters["num_mas"] = num_machines - - # sampling, each env contains multiple (=num_sample) copies of one instance - if test_parameters["sample"]: - env = gym.make('fjsp-v0', case=[instance_path] * test_parameters["num_sample"], env_paras=env_parameters, - data_source='file') - else: - # greedy, each env contains one instance - env = gym.make('fjsp-v0', case=[instance_path], env_paras=env_parameters, data_source='file') - makespan = schedule(env, model, PPO_model.Memory(), flag_sample=test_parameters["sample"]) - print(f"Instance: {instance_path}, Makespan: {makespan}") + run_method(**parameters) if __name__ == "__main__": diff --git a/run_basic_heuristics.py b/run_basic_heuristics.py index aac7942..f9889df 100644 --- a/run_basic_heuristics.py +++ b/run_basic_heuristics.py @@ -1,10 +1,9 @@ import argparse -import time import logging +import time -from solutions.helper_functions import * from plotting.drawer import draw_gantt_chart, draw_precedence_relations -from solutions.heuristics_scheduler.heuristics import * +from solution_methods.helper_functions import * logging.basicConfig(level=logging.INFO) PARAM_FILE = "configs/basic_heuristics.toml" diff --git a/run_dispatching_rules.py b/run_dispatching_rules.py index 44431f4..54aef4e 100644 --- a/run_dispatching_rules.py +++ b/run_dispatching_rules.py @@ -1,11 +1,11 @@ import argparse import logging -from scheduling_environment.simulationEnv import SimulationEnv from data_parsers import parser_fajsp, parser_fjsp, parser_jsp_fsp -from solutions.dispatching_rules.helper_functions import * -from solutions.helper_functions import load_parameters from plotting.drawer import draw_gantt_chart, draw_precedence_relations +from scheduling_environment.simulationEnv import SimulationEnv +from solution_methods.dispatching_rules.helper_functions import * +from solution_methods.helper_functions import load_parameters PARAM_FILE = "configs/dispatching_rules.toml" diff --git a/run_genetic_algorithm.py b/run_genetic_algorithm.py index b4fb5c6..6f9fc93 100644 --- a/run_genetic_algorithm.py +++ b/run_genetic_algorithm.py @@ -1,16 +1,16 @@ import argparse import logging import multiprocessing -import numpy as np -from deap import base, creator, tools from multiprocessing.pool import Pool +import numpy as np +from deap import base, creator, tools -from plotting.drawer import draw_precedence_relations, draw_gantt_chart -from solutions.helper_functions import record_stats, load_parameters, load_job_shop_env, dict_to_excel -from solutions.genetic_algorithm.operators import (evaluate_population, evaluate_individual, variation, - init_individual, init_population, mutate_shortest_proc_time, - mutate_sequence_exchange, pox_crossover, repair_precedence_constraints) +from plotting.drawer import draw_gantt_chart, draw_precedence_relations +from solution_methods.genetic_algorithm.operators import ( + evaluate_individual, evaluate_population, init_individual, init_population, mutate_sequence_exchange, + mutate_shortest_proc_time, pox_crossover, repair_precedence_constraints, variation) +from solution_methods.helper_functions import dict_to_excel, load_job_shop_env, load_parameters, record_stats logging.basicConfig(level=logging.INFO) @@ -70,7 +70,7 @@ def initialize_run(pool: Pool, **kwargs): return initial_population, toolbox, stats, hof, jobShopEnv -def algo_run(jobShopEnv, population, toolbox, folder, exp_name, stats=None, hof=None, **kwargs): +def run_method(jobShopEnv, population, toolbox, folder, exp_name, stats=None, hof=None, **kwargs): """Executes the genetic algorithm and returns the best individual. Args: @@ -161,7 +161,7 @@ def main(param_file=PARAM_FILE): exp_name = ("/rseed" + str(parameters['algorithm']["rseed"]) + "/") population, toolbox, stats, hof, jobShopEnv = initialize_run(pool, **parameters) - best_individual = algo_run(jobShopEnv, population, toolbox, folder, exp_name, stats, hof, **parameters) + best_individual = run_method(jobShopEnv, population, toolbox, folder, exp_name, stats, hof, **parameters) return best_individual diff --git a/run_milp.py b/run_milp.py index b3a9827..dbd349e 100644 --- a/run_milp.py +++ b/run_milp.py @@ -1,17 +1,19 @@ import argparse -from gurobipy import GRB -from solutions.MILP import FJSPSDSTmodel, FJSPmodel -from solutions.helper_functions import load_parameters -import logging import json +import logging import os +from gurobipy import GRB + +from solution_methods.helper_functions import load_parameters +from solution_methods.MILP import FJSPmodel, FJSPSDSTmodel, JSPmodel + logging.basicConfig(level=logging.INFO) DEFAULT_RESULTS_ROOT = "./results/milp" PARAM_FILE = "configs/milp.toml" -def main(param_file=PARAM_FILE): +def run_method(folder, exp_name, **kwargs): """ Solve the FJSP problem for the provided input file. @@ -21,23 +23,17 @@ def main(param_file=PARAM_FILE): Returns: None. Prints the optimization result. """ - try: - parameters = load_parameters(param_file) - except FileNotFoundError: - logging.error(f"Parameter file {param_file} not found.") - return - - folder = DEFAULT_RESULTS_ROOT - exp_name = "gurobi_" + str(parameters['solver']["time_limit"]) + "/" + \ - str(parameters['instance']['problem_instance']) + if 'fjsp_sdst' in str(kwargs['instance']['problem_instance']): + data = FJSPSDSTmodel.parse_file(kwargs['instance']['problem_instance']) + model = FJSPSDSTmodel.fjsp_sdst_milp(data, kwargs['solver']['time_limit']) + elif 'fjsp' in str(kwargs['instance']['problem_instance']): + data = FJSPmodel.parse_file(kwargs['instance']['problem_instance']) + model = FJSPmodel.fjsp_milp(data, kwargs['solver']['time_limit']) + elif 'jsp' in str(kwargs['instance']['problem_instance']): + data = JSPmodel.parse_file(kwargs['instance']['problem_instance']) + model = JSPmodel.jsp_milp(data, kwargs['solver']['time_limit']) - if 'fjsp_sdst' in str(parameters['instance']['problem_instance']): - data = FJSPSDSTmodel.parse_file(parameters['instance']['problem_instance']) - model = FJSPSDSTmodel.fjsp_sdst_milp(data, parameters['solver']['time_limit']) - elif 'fjsp' in str(parameters['instance']['problem_instance']): - data = FJSPmodel.parse_file(parameters['instance']['problem_instance']) - model = FJSPmodel.fjsp_milp(data, parameters['solver']['time_limit']) model.optimize() # Status dictionary mapping @@ -59,7 +55,7 @@ def main(param_file=PARAM_FILE): } results = { - 'time_limit': str(parameters['solver']["time_limit"]), + 'time_limit': str(kwargs['solver']["time_limit"]), 'status': model.status, 'statusString': status_dict.get(model.status, 'UNKNOWN'), 'objValue': model.objVal if model.status == GRB.OPTIMAL else None, @@ -86,6 +82,21 @@ def main(param_file=PARAM_FILE): json.dump(results, outfile, indent=4) +def main(param_file=PARAM_FILE): + + try: + parameters = load_parameters(param_file) + except FileNotFoundError: + logging.error(f"Parameter file {param_file} not found.") + return + + folder = DEFAULT_RESULTS_ROOT + + exp_name = "gurobi_" + str(parameters['solver']["time_limit"]) + "/" + str(parameters['instance']['problem_instance']) + + run_method(folder, exp_name, **parameters) + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Run MILP") parser.add_argument("config_file", @@ -96,4 +107,4 @@ def main(param_file=PARAM_FILE): help="path to config file", ) args = parser.parse_args() - main(param_file=args.config_file) \ No newline at end of file + main(param_file=args.config_file) diff --git a/run_or_tools.py b/run_or_tools.py new file mode 100644 index 0000000..f81ef0a --- /dev/null +++ b/run_or_tools.py @@ -0,0 +1,126 @@ +import argparse +import json +import logging +import os + +from solution_methods.helper_functions import load_parameters +from solution_methods.or_tools.FJSPmodel import fjsp_or_tools_model, parse_file_fjsp, parse_file_jsp, solve_model + +logging.basicConfig(level=logging.INFO) +DEFAULT_RESULTS_ROOT = "./results/or_tools" +PARAM_FILE = "configs/or_tools.toml" + + +def main(param_file: str = PARAM_FILE) -> None: + """ + Solve the (F)JSP problem for the provided input file. + + Args: + filename (str): Path to the file containing the (F)JSP data. + + Returns: + None. Prints the optimization result. + """ + try: + parameters = load_parameters(param_file) + except FileNotFoundError: + logging.error(f"Parameter file {param_file} not found.") + return + + folder = DEFAULT_RESULTS_ROOT + + exp_name = ( + "or_tools_" + str(parameters["solver"]["time_limit"]) + "/" + str(parameters["instance"]["problem_instance"]) + ) + + if "fjsp" in str(parameters["instance"]["problem_instance"]): + data = parse_file_fjsp(parameters["instance"]["problem_instance"]) + model, vars = fjsp_or_tools_model(data) + elif any( + scheduling_problem in str(parameters["instance"]["problem_instance"]) + for scheduling_problem in ["jsp", "fsp"] + ): + data = parse_file_jsp(parameters["instance"]["problem_instance"]) + model, vars = fjsp_or_tools_model(data) + solver, status, solution_count = solve_model( + model, parameters["solver"]["time_limit"] + ) + + # Gather Final Schedule + all_jobs = range(data["num_jobs"]) + jobs = data["jobs"] + starts = vars["starts"] + presences = vars["presences"] + + schedule = [] + for job_id in all_jobs: + job_info = {"job": job_id, "tasks": []} + print("Job %i:" % job_id) + for task_id in range(len(jobs[job_id])): + start_value = solver.Value(starts[(job_id, task_id)]) + machine = -1 + duration = -1 + selected = -1 + for alt_id in range(len(jobs[job_id][task_id])): + if solver.Value(presences[(job_id, task_id, alt_id)]): + duration = jobs[job_id][task_id][alt_id][0] + machine = jobs[job_id][task_id][alt_id][1] + selected = alt_id + print( + " task_%i_%i starts at %i (alt %i, machine %i, duration %i)" + % (job_id, task_id, start_value, selected, machine, duration) + ) + task_info = { + "task": task_id, + "start": start_value, + "machine": machine, + "duration": duration, + } + job_info["tasks"].append(task_info) + schedule.append(job_info) + # Status dictionary mapping + results = { + "time_limit": str(parameters["solver"]["time_limit"]), + "status": status, + "statusString": solver.StatusName(status), + "objValue": solver.ObjectiveValue(), + "runtime": solver.WallTime(), + "numBranches": solver.NumBranches(), + "conflicts": solver.NumConflicts(), + "solution_methods": solution_count, + "Schedule": schedule, + } + + # Ensure the directory exists; create if not + dir_path = os.path.join(folder, exp_name) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + # Specify the full path for the file + file_path = os.path.join(dir_path, "CP_results.json") + + # Save results to JSON (will create or overwrite the file) + with open(file_path, "w") as outfile: + json.dump(results, outfile, indent=4) + + # Print Results + print("Solve status: %s" % solver.StatusName(status)) + print("Optimal objective value: %i" % solver.ObjectiveValue()) + print("Statistics") + print(" - conflicts : %i" % solver.NumConflicts()) + print(" - branches : %i" % solver.NumBranches()) + print(" - wall time : %f s" % solver.WallTime()) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run OR-Tools CP-SAT") + parser.add_argument( + "config_file", + metavar="-f", + type=str, + nargs="?", + default=PARAM_FILE, + help="path to config file", + ) + args = parser.parse_args() + main(param_file=args.config_file) diff --git a/scheduling_environment/job.py b/scheduling_environment/job.py index 9028fff..0565e36 100644 --- a/scheduling_environment/job.py +++ b/scheduling_environment/job.py @@ -1,6 +1,7 @@ -from .operation import Operation from typing import List +from .operation import Operation + class Job: def __init__(self, job_id: int): @@ -26,6 +27,16 @@ def job_id(self) -> int: """Return the job's id.""" return self._job_id + @property + def scheduled_operations(self) -> List[Operation]: + """Return a list of operations that have been scheduled to a machine.""" + return [operation for operation in self._operations if operation.scheduling_information != {}] + + @property + def next_ope_earliest_begin_time(self): + """Returns the time at which all operations currently scheduled for this job have finished processing.""" + return max([operation.scheduled_end_time for operation in self.scheduled_operations], default=0) + def get_operation(self, operation_id): """Return operation object with operation id.""" for operation in self._operations: diff --git a/scheduling_environment/jobShop.py b/scheduling_environment/jobShop.py index 0bd4140..0a2b827 100644 --- a/scheduling_environment/jobShop.py +++ b/scheduling_environment/jobShop.py @@ -1,6 +1,7 @@ -from typing import List, Dict -from scheduling_environment.machine import Machine +from typing import Dict, List + from scheduling_environment.job import Job +from scheduling_environment.machine import Machine from scheduling_environment.operation import Operation @@ -171,7 +172,7 @@ def schedule_operation_with_backfilling(self, operation: Operation, machine_id, if not machine: raise ValueError( f"Invalid machine ID {machine_id}") - self.schedule_operation_on_machine_backfilling(operation, machine_id, duration) + machine.add_operation_to_schedule_backfilling(operation, duration, self._sequence_dependent_setup_times) self.mark_operation_as_scheduled(operation) def unschedule_operation(self, operation: Operation) -> None: @@ -180,15 +181,14 @@ def unschedule_operation(self, operation: Operation) -> None: machine.unschedule_operation(operation) self.mark_operation_as_available(operation) - def schedule_operation_on_machine_backfilling(self, operation: Operation, machine_id, duration) -> None: + def schedule_operation_on_machine(self, operation: Operation, machine_id, duration) -> None: """Schedule an operation on a specific machine.""" machine = self.get_machine(machine_id) if machine is None: raise ValueError( f"Invalid machine ID {machine_id}") - machine.add_operation_to_schedule_backfilling( - operation, duration, self._sequence_dependent_setup_times) + machine.add_operation_to_schedule(operation, duration, self._sequence_dependent_setup_times) def mark_operation_as_scheduled(self, operation: Operation) -> None: """Mark an operation as scheduled.""" diff --git a/scheduling_environment/machine.py b/scheduling_environment/machine.py index 867e942..25b1019 100644 --- a/scheduling_environment/machine.py +++ b/scheduling_environment/machine.py @@ -1,4 +1,5 @@ from typing import List + from scheduling_environment.operation import Operation @@ -30,6 +31,36 @@ def scheduled_operations(self) -> List[Operation]: sorted_operations = sorted(self._processed_operations, key=lambda op: op['start_time']) return [op['operation'] for op in sorted_operations] + @property + def next_available_time(self): + """Returns the time moment all currently scheduled operations have been finished on this machine.""" + return max([operation.scheduled_end_time for operation in self.scheduled_operations], default=0) + + def add_operation_to_schedule(self, operation: Operation, processing_time, sequence_dependent_setup_times): + """Add an operation to the scheduled operations list of the machine without backfilling.""" + + # find max finishing time predecessors + finishing_time_predecessors = operation.finishing_time_predecessors + finishing_time_machine = max([operation.scheduled_end_time for operation in self.scheduled_operations],default=0) + + setup_time = 0 + if len(self.scheduled_operations) != 0: + setup_time = \ + sequence_dependent_setup_times[self.machine_id][self.scheduled_operations[-1].operation_id][ + operation.operation_id] + start_time = max(finishing_time_predecessors, finishing_time_machine + setup_time) + operation.add_operation_scheduling_information(self.machine_id, start_time, setup_time, processing_time) + + self._processed_operations.append({ + 'operation': operation, + 'start_time': start_time, + 'end_time': start_time + processing_time, + 'processing_time': processing_time, + 'start_setup': start_time-setup_time, + 'end_setup': start_time, + 'setup_time': setup_time + }) + def add_operation_to_schedule_at_time(self, operation, start_time, processing_time, setup_time): """Scheduled an operations at a certain time.""" diff --git a/scheduling_environment/operation.py b/scheduling_environment/operation.py index c97c498..fd67f4a 100644 --- a/scheduling_environment/operation.py +++ b/scheduling_environment/operation.py @@ -1,5 +1,5 @@ -from typing import List, Dict from collections import OrderedDict +from typing import Dict, List class Operation: @@ -34,7 +34,7 @@ def operation_id(self) -> int: @property def scheduling_information(self) -> Dict: - """Return the scheduling information of the operation""" + """Return the scheduling information of the operation.""" return self._scheduling_information @property @@ -59,7 +59,9 @@ def scheduled_end_time(self) -> int: @property def scheduled_duration(self) -> int: """Return the scheduled duration of the operation.""" - return self._scheduled_duration + if 'processing_time' in self._scheduling_information: + return self._scheduling_information['processing_time'] + return None @property def scheduled_machine(self) -> None: @@ -73,6 +75,11 @@ def predecessors(self) -> List: """Return the list of predecessor operations.""" return self._predecessors + @property + def optional_machines_id(self) -> List: + """Returns the list of machine ids that are eligible for processing this operation.""" + return list(self._processing_times.keys()) + @property def finishing_time_predecessors(self) -> int: """Return the finishing time of the latest predecessor.""" @@ -82,10 +89,11 @@ def finishing_time_predecessors(self) -> int: return max(end_times_predecessors) def update_job_id(self, new_job_id: int) -> None: - """Update the id of a job (used for assembly scheduling problems, with no pregiven job id)""" + """Update the id of a job (used for assembly scheduling problems, with no pre-given job id).""" self._job_id = new_job_id def update_job(self, job) -> None: + """Update job information (edge case for FAJSP).""" self._job = job def add_predecessors(self, predecessors: List) -> None: @@ -93,10 +101,11 @@ def add_predecessors(self, predecessors: List) -> None: self.predecessors.extend(predecessors) def add_operation_option(self, machine_id, duration) -> None: - """Add an operation option to the current operation.""" + """Add an machine option to the current operation.""" self._processing_times[machine_id] = duration def update_sequence_dependent_setup_times(self, start_time_setup, setup_duration): + """Update the sequence dependent setup times of this operation.""" self._start_time_setup = start_time_setup self._setup_duration = setup_duration diff --git a/scheduling_environment/simulationEnv.py b/scheduling_environment/simulationEnv.py index cf58126..57e44f9 100644 --- a/scheduling_environment/simulationEnv.py +++ b/scheduling_environment/simulationEnv.py @@ -1,10 +1,11 @@ -import simpy import random -from typing import Optional, List, Dict +from typing import Dict, List, Optional + +import simpy +from scheduling_environment.job import Job from scheduling_environment.jobShop import JobShop from scheduling_environment.machine import Machine -from scheduling_environment.job import Job from scheduling_environment.operation import Operation diff --git a/solutions/FJSP_DRL/PPO_model.py b/solution_methods/FJSP_DRL/PPO.py similarity index 92% rename from solutions/FJSP_DRL/PPO_model.py rename to solution_methods/FJSP_DRL/PPO.py index de149b4..081e997 100644 --- a/solutions/FJSP_DRL/PPO_model.py +++ b/solution_methods/FJSP_DRL/PPO.py @@ -8,13 +8,14 @@ import copy import math + import torch import torch.nn as nn import torch.nn.functional as F from torch.distributions import Categorical -from solutions.FJSP_DRL.hgnn import GATedge, MLPsim -from solutions.FJSP_DRL.mlp import MLPCritic, MLPActor +from solution_methods.FJSP_DRL.hgnn import GATedge, MLPsim +from solution_methods.FJSP_DRL.mlp import MLPActor, MLPCritic class Memory: @@ -99,12 +100,19 @@ def forward(self, ope_ma_adj_batch, ope_pre_adj_batch, ope_sub_adj_batch, batch_ """ h = (feats[1], feats[0], feats[0], feats[0]) # Identity matrix for self-loop of nodes - self_adj = torch.eye(feats[0].size(-2), - dtype=torch.int64).unsqueeze(0).expand_as(ope_pre_adj_batch[batch_idxes]) + self_adj = ( + torch.eye(feats[0].size(-2), dtype=torch.int64) + .unsqueeze(0) + .expand_as(ope_pre_adj_batch[batch_idxes]) + ) # Calculate an return operation embedding - adj = (ope_ma_adj_batch[batch_idxes], ope_pre_adj_batch[batch_idxes], - ope_sub_adj_batch[batch_idxes], self_adj) + adj = ( + ope_ma_adj_batch[batch_idxes], + ope_pre_adj_batch[batch_idxes], + ope_sub_adj_batch[batch_idxes], + self_adj, + ) MLP_embeddings = [] for i in range(len(adj)): MLP_embeddings.append(self.gnn_layers[i](h[i], adj[i])) @@ -163,11 +171,11 @@ def forward(self): def feature_normalize(self, data): return (data - torch.mean(data)) / ((data.std() + 1e-5)) - ''' + """ raw_opes: shape: [len(batch_idxes), max(num_opes), in_size_ope] raw_mas: shape: [len(batch_idxes), num_mas, in_size_ma] proc_time: shape: [len(batch_idxes), max(num_opes), num_mas] - ''' + """ def get_normalized(self, raw_opes, raw_mas, proc_time, batch_idxes, nums_opes, flag_sample=False, flag_train=False): """ @@ -243,7 +251,7 @@ def get_action_prob(self, state, memories, flag_sample=False, flag_train=False): if not flag_sample and not flag_train: h_opes_pooled = [] for i in range(len(batch_idxes)): - h_opes_pooled.append(torch.mean(h_opes[i, :nums_opes[i], :], dim=-2)) + h_opes_pooled.append(torch.mean(h_opes[i, : nums_opes[i], :], dim=-2)) h_opes_pooled = torch.stack(h_opes_pooled) # shape: [len(batch_idxes), d] else: h_opes_pooled = h_opes.mean(dim=-2) # shape: [len(batch_idxes), out_size_ope] @@ -269,8 +277,7 @@ def get_action_prob(self, state, memories, flag_sample=False, flag_train=False): ma_eligible = ~state.mask_ma_procing_batch[batch_idxes].unsqueeze(1).expand_as(h_jobs_padding[..., 0]) # Matrix indicating whether job is eligible # shape: [len(batch_idxes), num_jobs, num_mas] - job_eligible = ~(state.mask_job_procing_batch[batch_idxes] + - state.mask_job_finish_batch[batch_idxes])[:, :, None].expand_as(h_jobs_padding[..., 0]) + job_eligible = ~(state.mask_job_procing_batch[batch_idxes] + state.mask_job_finish_batch[batch_idxes])[:, :, None].expand_as(h_jobs_padding[..., 0]) # shape: [len(batch_idxes), num_jobs, num_mas] eligible = job_eligible & ma_eligible & (eligible_proc == 1) if (~(eligible)).all(): @@ -285,11 +292,11 @@ def get_action_prob(self, state, memories, flag_sample=False, flag_train=False): # Get priority index and probability of actions with masking the ineligible actions scores = self.actor(h_actions).flatten(1) - scores[~mask] = float('-inf') + scores[~mask] = float("-inf") action_probs = F.softmax(scores, dim=1) # Store data in memory during training - if flag_train == True: + if flag_train is True: memories.ope_ma_adj.append(copy.deepcopy(state.ope_ma_adj_batch)) memories.ope_pre_adj.append(copy.deepcopy(state.ope_pre_adj_batch)) memories.ope_sub_adj.append(copy.deepcopy(state.ope_sub_adj_batch)) @@ -321,8 +328,7 @@ def act(self, state, memories, dones, flag_sample=True, flag_train=True): opes = ope_step_batch[state.batch_idxes, jobs] # Store data in memory during training - if flag_train == True: - # memories.states.append(copy.deepcopy(state)) + if flag_train is True: memories.logprobs.append(dist.log_prob(action_indexes)) memories.action_indexes.append(action_indexes) @@ -357,7 +363,7 @@ def evaluate(self, ope_ma_adj, ope_pre_adj, ope_sub_adj, raw_opes, raw_mas, proc scores = self.actor(h_actions).flatten(1) mask = eligible.transpose(1, 2).flatten(1) - scores[~mask] = float('-inf') + scores[~mask] = float("-inf") action_probs = F.softmax(scores, dim=1) state_values = self.critic(h_pooled) dist = Categorical(action_probs.squeeze()) @@ -433,24 +439,23 @@ def update(self, memory, env_paras, train_paras): else: start_idx = i * minibatch_size end_idx = full_batch_size - logprobs, state_values, dist_entropy = \ - self.policy.evaluate(old_ope_ma_adj[start_idx: end_idx, :, :], - old_ope_pre_adj[start_idx: end_idx, :, :], - old_ope_sub_adj[start_idx: end_idx, :, :], - old_raw_opes[start_idx: end_idx, :, :], - old_raw_mas[start_idx: end_idx, :, :], - old_proc_time[start_idx: end_idx, :, :], - old_jobs_gather[start_idx: end_idx, :, :], - old_eligible[start_idx: end_idx, :, :], - old_action_envs[start_idx: end_idx]) + logprobs, state_values, dist_entropy = self.policy.evaluate( + old_ope_ma_adj[start_idx:end_idx, :, :], + old_ope_pre_adj[start_idx:end_idx, :, :], + old_ope_sub_adj[start_idx:end_idx, :, :], + old_raw_opes[start_idx:end_idx, :, :], + old_raw_mas[start_idx:end_idx, :, :], + old_proc_time[start_idx:end_idx, :, :], + old_jobs_gather[start_idx:end_idx, :, :], + old_eligible[start_idx:end_idx, :, :], + old_action_envs[start_idx:end_idx], + ) ratios = torch.exp(logprobs - old_logprobs[i * minibatch_size:(i + 1) * minibatch_size].detach()) advantages = rewards_envs[i * minibatch_size:(i + 1) * minibatch_size] - state_values.detach() surr1 = ratios * advantages surr2 = torch.clamp(ratios, 1 - self.eps_clip, 1 + self.eps_clip) * advantages - loss = - self.A_coeff * torch.min(surr1, surr2) \ - + self.vf_coeff * self.MseLoss(state_values, - rewards_envs[i * minibatch_size:(i + 1) * minibatch_size]) \ + loss = - self.A_coeff * torch.min(surr1, surr2) + self.vf_coeff * self.MseLoss(state_values, rewards_envs[i * minibatch_size:(i + 1) * minibatch_size]) \ - self.entropy_coeff * dist_entropy loss_epochs += loss.mean().detach() @@ -461,5 +466,6 @@ def update(self, memory, env_paras, train_paras): # Copy new weights into old policy: self.policy_old.load_state_dict(self.policy.state_dict()) - return loss_epochs.item() / self.K_epochs, \ - discounted_rewards.item() / (self.num_envs * train_paras["update_timestep"]) + return loss_epochs.item() / self.K_epochs, discounted_rewards.item() / ( + self.num_envs * train_paras["update_timestep"] + ) diff --git a/solution_methods/FJSP_DRL/case_generator.py b/solution_methods/FJSP_DRL/case_generator.py new file mode 100644 index 0000000..819af80 --- /dev/null +++ b/solution_methods/FJSP_DRL/case_generator.py @@ -0,0 +1,95 @@ +import random + + +class CaseGenerator: + ''' + FJSP instance generator + ''' + def __init__(self, job_init, num_mas, opes_per_job_min, opes_per_job_max, nums_ope=None, path='../data/', + flag_same_opes=True, flag_doc=False): + if nums_ope is None: + nums_ope = [] + self.flag_doc = flag_doc # Whether save the instance to a file + self.flag_same_opes = flag_same_opes + self.nums_ope = nums_ope + self.path = path # Instance save path (relative path) + self.job_init = job_init + self.num_mas = num_mas + + self.mas_per_ope_min = 1 # The minimum number of machines that can process an operation + self.mas_per_ope_max = num_mas + self.opes_per_job_min = opes_per_job_min # The minimum number of operations for a job + self.opes_per_job_max = opes_per_job_max + self.proctime_per_ope_min = 1 # Minimum average processing time + self.proctime_per_ope_max = 20 + self.proctime_dev = 0.2 + + def get_case(self, idx=0): + ''' + Generate FJSP instance + :param idx: The instance number + ''' + self.num_jobs = self.job_init + if not self.flag_same_opes: + self.nums_ope = [random.randint(self.opes_per_job_min, self.opes_per_job_max) for _ in range(self.num_jobs)] + self.num_opes = sum(self.nums_ope) + self.nums_option = [random.randint(self.mas_per_ope_min, self.mas_per_ope_max) for _ in range(self.num_opes)] + self.num_options = sum(self.nums_option) + self.ope_ma = [] + for val in self.nums_option: + self.ope_ma = self.ope_ma + sorted(random.sample(range(1, self.num_mas + 1), val)) + self.proc_time = [] + self.proc_times_mean = [random.randint(self.proctime_per_ope_min, self.proctime_per_ope_max) for _ in range(self.num_opes)] + for i in range(len(self.nums_option)): + low_bound = max(self.proctime_per_ope_min, round(self.proc_times_mean[i] * (1 - self.proctime_dev))) + high_bound = min(self.proctime_per_ope_max, round(self.proc_times_mean[i] * (1 + self.proctime_dev))) + proc_time_ope = [random.randint(low_bound, high_bound) for _ in range(self.nums_option[i])] + self.proc_time = self.proc_time + proc_time_ope + self.num_ope_biass = [sum(self.nums_ope[0:i]) for i in range(self.num_jobs)] + self.num_ma_biass = [sum(self.nums_option[0:i]) for i in range(self.num_opes)] + line0 = '{0}\t{1}\t{2}\n'.format(self.num_jobs, self.num_mas, self.num_options / self.num_opes) + lines = [] + lines_doc = [] + lines.append(line0) + lines_doc.append('{0}\t{1}\t{2}'.format(self.num_jobs, self.num_mas, self.num_options / self.num_opes)) + for i in range(self.num_jobs): + flag = 0 + flag_time = 0 + flag_new_ope = 1 + idx_ope = -1 + idx_ma = 0 + line = [] + option_max = sum(self.nums_option[self.num_ope_biass[i]:(self.num_ope_biass[i] + self.nums_ope[i])]) + idx_option = 0 + while True: + if flag == 0: + line.append(self.nums_ope[i]) + flag += 1 + elif flag == flag_new_ope: + idx_ope += 1 + idx_ma = 0 + flag_new_ope += self.nums_option[self.num_ope_biass[i] + idx_ope] * 2 + 1 + line.append(self.nums_option[self.num_ope_biass[i] + idx_ope]) + flag += 1 + elif flag_time == 0: + line.append(self.ope_ma[self.num_ma_biass[self.num_ope_biass[i] + idx_ope] + idx_ma]) + flag += 1 + flag_time = 1 + else: + line.append(self.proc_time[self.num_ma_biass[self.num_ope_biass[i] + idx_ope] + idx_ma]) + flag += 1 + flag_time = 0 + idx_option += 1 + idx_ma += 1 + if idx_option == option_max: + str_line = " ".join([str(val) for val in line]) + lines.append(str_line + '\n') + lines_doc.append(str_line) + break + lines.append('\n') + if self.flag_doc: + doc = open(self.path + '{0}j_{1}m_{2}.fjs'.format(self.num_jobs, self.num_mas, str.zfill(str(idx + 1), 3)), 'a') + for i in range(len(lines_doc)): + print(lines_doc[i], file=doc) + doc.close() + return lines, self.num_jobs, self.num_jobs diff --git a/solution_methods/FJSP_DRL/env_test.py b/solution_methods/FJSP_DRL/env_test.py new file mode 100644 index 0000000..b4c517e --- /dev/null +++ b/solution_methods/FJSP_DRL/env_test.py @@ -0,0 +1,324 @@ +# GITHUB REPO: https://github.com/songwenas12/fjsp-drl + +# Code based on the paper: +# "Flexible Job Shop Scheduling via Graph Neural Network and Deep Reinforcement Learning" +# by Wen Song, Xinyang Chen, Qiqiang Li and Zhiguang Cao +# Presented in IEEE Transactions on Industrial Informatics, 2023. +# Paper URL: https://ieeexplore.ieee.org/document/9826438 + +import copy +import sys +from dataclasses import dataclass +from pathlib import Path + +import torch + +from scheduling_environment.jobShop import JobShop +from solution_methods.FJSP_DRL.load_data import load_feats_from_sim + +# Add the base path to the Python module search path +base_path = Path(__file__).resolve().parents[2] +sys.path.append(str(base_path)) + + +@dataclass +class EnvState: + ''' + Class for the state of the environment + ''' + # static + opes_appertain_batch: torch.Tensor = None + ope_pre_adj_batch: torch.Tensor = None + ope_sub_adj_batch: torch.Tensor = None + end_ope_biases_batch: torch.Tensor = None + nums_opes_batch: torch.Tensor = None + + # dynamic + batch_idxes: torch.Tensor = None + feat_opes_batch: torch.Tensor = None + feat_mas_batch: torch.Tensor = None + proc_times_batch: torch.Tensor = None + ope_ma_adj_batch: torch.Tensor = None + time_batch: torch.Tensor = None + + mask_job_procing_batch: torch.Tensor = None + mask_job_finish_batch: torch.Tensor = None + mask_ma_procing_batch: torch.Tensor = None + ope_step_batch: torch.Tensor = None + + def update(self, batch_idxes, feat_opes_batch, feat_mas_batch, proc_times_batch, ope_ma_adj_batch, + mask_job_procing_batch, mask_job_finish_batch, mask_ma_procing_batch, ope_step_batch, time): + self.batch_idxes = batch_idxes + self.feat_opes_batch = feat_opes_batch + self.feat_mas_batch = feat_mas_batch + self.proc_times_batch = proc_times_batch + self.ope_ma_adj_batch = ope_ma_adj_batch + + self.mask_job_procing_batch = mask_job_procing_batch + self.mask_job_finish_batch = mask_job_finish_batch + self.mask_ma_procing_batch = mask_ma_procing_batch + self.ope_step_batch = ope_step_batch + self.time_batch = time + + +def convert_feat_job_2_ope(feat_job_batch, opes_appertain_batch): + """ + Convert job features into operation features (such as dimension) + """ + return feat_job_batch.gather(1, opes_appertain_batch) + + +class FJSPEnv_test(): + def __init__(self, JobShop_module, test_parameters): + # static + self.batch_size = 1 + self.num_jobs = JobShop_module.nr_of_jobs # Number of jobs + self.num_mas = JobShop_module.nr_of_machines # Number of machines + self.device = test_parameters["device"] # Computing device for PyTorch + + self.JSP_instance: list[JobShop] = JobShop_module + + # load instance + num_data = 8 # The amount of data extracted from instance + tensors = [[] for _ in range(num_data)] + self.num_opes = 0 + self.num_opes = max(self.num_jobs, len(self.JSP_instance.operations)) + + # Extract features from each JobShop module + raw_features = load_feats_from_sim(self.JSP_instance, self.num_mas, self.num_opes) + # print(raw_features[0].shape) + for j in range(num_data): + tensors[j].append(raw_features[j].to(self.device)) + + # dynamic feats + # shape: (batch_size, num_opes, num_mas) + self.proc_times_batch = torch.stack(tensors[0], dim=0) + # shape: (batch_size, num_opes, num_mas) + self.ope_ma_adj_batch = torch.stack(tensors[1], dim=0).long() + # shape: (batch_size, num_opes, num_opes), for calculating the cumulative amount along the path of each job + self.cal_cumul_adj_batch = torch.stack(tensors[7], dim=0).float() + + # static feats + # shape: (batch_size, num_opes, num_opes) + self.ope_pre_adj_batch = torch.stack(tensors[2], dim=0) + # shape: (batch_size, num_opes, num_opes) + self.ope_sub_adj_batch = torch.stack(tensors[3], dim=0) + # shape: (batch_size, num_opes), represents the mapping between operations and jobs + self.opes_appertain_batch = torch.stack(tensors[4], dim=0).long() + # shape: (batch_size, num_jobs), the id of the first operation of each job + self.num_ope_biases_batch = torch.stack(tensors[5], dim=0).long() + # shape: (batch_size, num_jobs), the number of operations for each job + self.nums_ope_batch = torch.stack(tensors[6], dim=0).long() + # shape: (batch_size, num_jobs), the id of the last operation of each job + self.end_ope_biases_batch = self.num_ope_biases_batch + self.nums_ope_batch - 1 + # shape: (batch_size), the number of operations for each instance + self.nums_opes = torch.sum(self.nums_ope_batch, dim=1) + + # dynamic variable + self.batch_idxes = torch.arange(self.batch_size) # Uncompleted instances + self.JSM_time = torch.zeros(self.batch_size) # Current time of the environment + self.N = torch.zeros(self.batch_size).int() # Count scheduled operations + # shape: (batch_size, num_jobs), the id of the current operation (be waiting to be processed) of each job + self.ope_step_batch = copy.deepcopy(self.num_ope_biases_batch) + + # Generate raw feature vectors + ope_feat_dim = 6 + ma_feat_dim = 3 + num_sample = 1 + feat_opes_batch = torch.zeros(size=(num_sample, ope_feat_dim, self.num_opes)) + feat_mas_batch = torch.zeros(size=(num_sample, ma_feat_dim, self.num_mas)) + + feat_opes_batch[:, 1, :] = torch.count_nonzero(self.ope_ma_adj_batch, dim=2) + feat_opes_batch[:, 2, :] = torch.sum(self.proc_times_batch, dim=2).div(feat_opes_batch[:, 1, :] + 1e-9) + feat_opes_batch[:, 3, :] = convert_feat_job_2_ope(self.nums_ope_batch, self.opes_appertain_batch) + feat_opes_batch[:, 5, :] = torch.bmm(feat_opes_batch[:, 2, :].unsqueeze(1), self.cal_cumul_adj_batch).squeeze() + end_time_batch = (feat_opes_batch[:, 5, :] + feat_opes_batch[:, 2, :]).gather(1, self.end_ope_biases_batch) + feat_opes_batch[:, 4, :] = convert_feat_job_2_ope(end_time_batch, self.opes_appertain_batch) + feat_mas_batch[:, 0, :] = torch.count_nonzero(self.ope_ma_adj_batch, dim=1) + self.JSM_feat_opes_batch = feat_opes_batch + self.JSM_feat_mas_batch = feat_mas_batch + + # Masks of current status, dynamic + # shape: (batch_size, num_jobs), True for jobs in process + self.JSM_mask_job_procing_batch = torch.full(size=(self.batch_size, self.num_jobs), dtype=torch.bool, + fill_value=False) + # shape: (batch_size, num_jobs), True for completed jobs + self.JSM_mask_job_finish_batch = torch.full(size=(self.batch_size, self.num_jobs), dtype=torch.bool, + fill_value=False) + # shape: (batch_size, num_mas), True for machines in process + self.JSM_mask_ma_procing_batch = torch.full(size=(self.batch_size, self.num_mas), dtype=torch.bool, + fill_value=False) + + self.makespan_batch = torch.max(self.JSM_feat_opes_batch[:, 4, :], dim=1)[0] # shape: (batch_size) + self.done_batch = self.JSM_mask_job_finish_batch.all(dim=1) # shape: (batch_size) + + self.state = EnvState(batch_idxes=self.batch_idxes, + feat_opes_batch=self.JSM_feat_opes_batch, feat_mas_batch=self.JSM_feat_mas_batch, + proc_times_batch=self.proc_times_batch, ope_ma_adj_batch=self.ope_ma_adj_batch, + ope_pre_adj_batch=self.ope_pre_adj_batch, ope_sub_adj_batch=self.ope_sub_adj_batch, + mask_job_procing_batch=self.JSM_mask_job_procing_batch, + mask_job_finish_batch=self.JSM_mask_job_finish_batch, + mask_ma_procing_batch=self.JSM_mask_ma_procing_batch, + opes_appertain_batch=self.opes_appertain_batch, + ope_step_batch=self.ope_step_batch, + end_ope_biases_batch=self.end_ope_biases_batch, + time_batch=self.JSM_time, nums_opes_batch=self.nums_opes) + + # Save initial data for reset + self.old_proc_times_batch = copy.deepcopy(self.proc_times_batch) + self.old_ope_ma_adj_batch = copy.deepcopy(self.ope_ma_adj_batch) + self.old_cal_cumul_adj_batch = copy.deepcopy(self.cal_cumul_adj_batch) + self.old_feat_opes_batch = copy.deepcopy(self.JSM_feat_opes_batch) + self.old_feat_mas_batch = copy.deepcopy(self.JSM_feat_mas_batch) + self.old_state = copy.deepcopy(self.state) + + def step(self, actions): + """ + Environment transition function, based on JobShop module + """ + opes = actions[0, :] + mas = actions[1, :] + jobs = actions[2, :] + self.N += 1 + + ope_idx = opes.item() + mac_idx = mas.item() + env = self.JSP_instance + operation = env.operations[ope_idx] + duration = operation.processing_times[mac_idx] + env.schedule_operation_on_machine(operation, mac_idx, duration) + env.get_job(operation.job_id).scheduled_operations.append(operation) + + # Removed unselected O-M arcs of the scheduled operations + remain_ope_ma_adj = torch.zeros(size=(self.batch_size, self.num_mas), dtype=torch.int64) + remain_ope_ma_adj[self.batch_idxes, mas] = 1 + self.ope_ma_adj_batch[self.batch_idxes, opes] = remain_ope_ma_adj[self.batch_idxes, :] + self.proc_times_batch *= self.ope_ma_adj_batch + + # Update for some O-M arcs are removed, such as 'Status', 'Number of neighboring machines' and 'Processing time' + proc_times = self.proc_times_batch[self.batch_idxes, opes, mas] + self.JSM_feat_opes_batch[self.batch_idxes, :3, opes] = torch.stack( + (torch.ones(self.batch_idxes.size(0), dtype=torch.float), + torch.ones(self.batch_idxes.size(0), dtype=torch.float), + proc_times), dim=1) + + # Update 'Number of unscheduled operations in the job' - use JobShop + + job_idx = jobs.item() + unscheduled_opes = 0 + for each_ope in self.JSP_instance.get_job(job_idx).operations: + if each_ope.scheduling_information.__len__() == 0: + unscheduled_opes += 1 + start_ope_idx = self.num_ope_biases_batch[self.batch_idxes, jobs] + end_ope_idx = self.end_ope_biases_batch[self.batch_idxes, jobs] + self.JSM_feat_opes_batch[self.batch_idxes, 3, start_ope_idx:end_ope_idx + 1] = unscheduled_opes + + # Update 'Start time' and 'Job completion time' - use JobShop + self.JSM_feat_opes_batch[self.batch_idxes, 5, opes] = self.JSM_time + for each_ope in self.JSP_instance.operations: + if each_ope.scheduling_information.__len__() == 0: + if each_ope.predecessors.__len__() == 0: + est_start_time = self.JSM_feat_opes_batch[self.batch_idxes, 5, each_ope.operation_id] + else: + if each_ope.predecessors[0].scheduling_information.__len__() == 0: + est_start_time = self.JSM_feat_opes_batch[self.batch_idxes, 5, each_ope.predecessors[0].operation_id] + self.JSM_feat_opes_batch[self.batch_idxes, 2, each_ope.predecessors[0].operation_id] + else: + est_start_time = each_ope.predecessors[0].scheduling_information['start_time'] + each_ope.predecessors[0].scheduling_information['processing_time'] + else: + est_start_time = each_ope.scheduling_information['start_time'] + self.JSM_feat_opes_batch[self.batch_idxes, 5, each_ope.operation_id] = est_start_time + + for each_job in self.JSP_instance.jobs: + est_end_times = [(self.JSM_feat_opes_batch[self.batch_idxes, 5, ope_in_job.operation_id] + self.JSM_feat_opes_batch[self.batch_idxes, 2, ope_in_job.operation_id]) for ope_in_job in each_job.operations] + job_end_time = max(est_end_times) + for ope_of_job in each_job.operations: + self.JSM_feat_opes_batch[self.batch_idxes, 4, ope_of_job.operation_id] = job_end_time + + # Check if there are still O-M pairs to be processed, otherwise the environment transits to the next time - using JobShop Module + this_env = self.JSP_instance + cur_times = [] + for each_job in this_env.jobs: + if len(each_job.scheduled_operations) == len(each_job.operations): + continue + next_ope = each_job.operations[len(each_job.scheduled_operations)] + latest_ope_end_time = next_ope.finishing_time_predecessors + for each_mach_id in next_ope.optional_machines_id: + # if schedule the next operation of this job on an optional machine, the earlist start time + # operation available: predecessor operation end + # machine available: last assigned operation end + cur_times.append(max(latest_ope_end_time, this_env.get_machine(each_mach_id).next_available_time, self.JSM_time)) + self.JSM_time = min(cur_times, default=self.JSM_time) + + # Update feature vectors of machines - using JobShop module + self.JSM_feat_mas_batch[self.batch_idxes, 0, :] = torch.count_nonzero(self.ope_ma_adj_batch[self.batch_idxes, :, :], dim=1).float() + for each_mach in self.JSP_instance.machines: + workload = sum([ope_on_mach.scheduled_duration for ope_on_mach in each_mach.scheduled_operations]) + cur_time = self.JSM_time + workload = min(cur_time, workload) + self.JSM_feat_mas_batch[self.batch_idxes, 2, each_mach.machine_id] = workload / (cur_time + 1e-9) + self.JSM_feat_mas_batch[self.batch_idxes, 1, each_mach.machine_id] = each_mach.next_available_time + + # Update other variable according to actions - using JobShop module + self.ope_step_batch[self.batch_idxes, jobs] += 1 + JSM_mask_jp_list = [] + JSM_mask_jf_list = [] + JSM_mask_mp_list = [] + JSM_mask_jp_list.append([True if this_job.next_ope_earliest_begin_time > self.JSM_time else False + for this_job in self.JSP_instance.jobs]) + JSM_mask_jf_list.append([True if this_job.operations.__len__() == this_job.scheduled_operations.__len__() else False for this_job in self.JSP_instance.jobs]) + JSM_mask_mp_list.append([True if this_mach.next_available_time > self.JSM_time else False for this_mach in self.JSP_instance.machines]) + self.JSM_mask_job_procing_batch = torch.tensor(JSM_mask_jp_list, dtype=torch.bool) + self.JSM_mask_job_finish_batch = torch.tensor(JSM_mask_jf_list, dtype=torch.bool) + self.JSM_mask_ma_procing_batch = torch.tensor(JSM_mask_mp_list, dtype=torch.bool) + + self.done_batch = self.JSM_mask_job_finish_batch.all(dim=1) + self.done = self.done_batch.all() + + max_makespan = torch.max(self.JSM_feat_opes_batch[:, 4, :], dim=1)[0] + self.reward_batch = self.makespan_batch - max_makespan + self.makespan_batch = max_makespan + + # Update the vector for uncompleted instances + mask_finish = (self.N + 1) <= self.nums_opes + if ~(mask_finish.all()): + self.batch_idxes = torch.arange(self.batch_size)[mask_finish] + + # Update state of the environment + self.state.update(self.batch_idxes, self.JSM_feat_opes_batch, self.JSM_feat_mas_batch, self.proc_times_batch, + self.ope_ma_adj_batch, self.JSM_mask_job_procing_batch, self.JSM_mask_job_finish_batch, + self.JSM_mask_ma_procing_batch, self.ope_step_batch, self.JSM_time) + return self.state, self.reward_batch, self.done_batch + + def reset(self): + """ + Reset the environment to its initial state + """ + for i in range(self.batch_size): + self.JSP_instance[i].reset() + + self.proc_times_batch = copy.deepcopy(self.old_proc_times_batch) + self.ope_ma_adj_batch = copy.deepcopy(self.old_ope_ma_adj_batch) + self.cal_cumul_adj_batch = copy.deepcopy(self.old_cal_cumul_adj_batch) + self.JSM_feat_opes_batch = copy.deepcopy(self.old_feat_opes_batch) + self.JSM_feat_mas_batch = copy.deepcopy(self.old_feat_mas_batch) + self.state = copy.deepcopy(self.old_state) + + self.batch_idxes = torch.arange(self.batch_size) + + self.JSM_time = torch.zeros(self.batch_size) + self.N = torch.zeros(self.batch_size) + self.ope_step_batch = copy.deepcopy(self.num_ope_biases_batch) + + self.JSM_mask_job_procing_batch = torch.full(size=(self.batch_size, self.num_jobs), dtype=torch.bool, + fill_value=False) + self.JSM_mask_job_finish_batch = torch.full(size=(self.batch_size, self.num_jobs), dtype=torch.bool, + fill_value=False) + self.JSM_mask_ma_procing_batch = torch.full(size=(self.batch_size, self.num_mas), dtype=torch.bool, + fill_value=False) + self.machines_batch = torch.zeros(size=(self.batch_size, self.num_mas, 4)) + self.machines_batch[:, :, 0] = torch.ones(size=(self.batch_size, self.num_mas)) + + self.makespan_batch = torch.max(self.JSM_feat_opes_batch[:, 4, :], dim=1)[0] + self.done_batch = self.JSM_mask_job_finish_batch.all(dim=1) + + return self.state diff --git a/solutions/FJSP_DRL/env.py b/solution_methods/FJSP_DRL/env_training.py similarity index 77% rename from solutions/FJSP_DRL/env.py rename to solution_methods/FJSP_DRL/env_training.py index f0c00e9..30c90df 100644 --- a/solutions/FJSP_DRL/env.py +++ b/solution_methods/FJSP_DRL/env_training.py @@ -1,35 +1,17 @@ -# GITHUB REPO: https://github.com/songwenas12/fjsp-drl - -# Code based on the paper: -# "Flexible Job Shop Scheduling via Graph Neural Network and Deep Reinforcement Learning" -# by Wen Song, Xinyang Chen, Qiqiang Li and Zhiguang Cao -# Presented in IEEE Transactions on Industrial Informatics, 2023. -# Paper URL: https://ieeexplore.ieee.org/document/9826438 - -import random -import sys import copy - -import gymnasium as gym -import torch -import numpy as np -from pathlib import Path from dataclasses import dataclass -from solutions.FJSP_DRL.load_data import load_fjs, nums_detec, load_fjs_case -from scheduling_environment.jobShop import JobShop - +import numpy as np +import torch -# Add the base path to the Python module search path -base_path = Path(__file__).resolve().parents[2] -sys.path.append(str(base_path)) +from solution_methods.FJSP_DRL.load_data import load_feats_from_case, nums_detec @dataclass class EnvState: - ''' + """ Class for the state of the environment - ''' + """ # static opes_appertain_batch: torch.Tensor = None ope_pre_adj_batch: torch.Tensor = None @@ -72,21 +54,20 @@ def convert_feat_job_2_ope(feat_job_batch, opes_appertain_batch): return feat_job_batch.gather(1, opes_appertain_batch) -class FJSPEnv(gym.Env): +class FJSPEnv_training(): """ FJSP environment """ def __init__(self, case, env_paras, data_source='case'): - ''' + """ :param case: The instance generator or the addresses of the instances :param env_paras: A dictionary of parameters for the environment :param data_source: Indicates that the instances came from a generator or files - ''' + """ # load paras # static - self.show_mode = env_paras["show_mode"] # Result display mode (deprecated in the final experiment) self.batch_size = env_paras["batch_size"] # Number of parallel instances during training self.num_jobs = env_paras["num_jobs"] # Number of jobs self.num_mas = env_paras["num_mas"] # Number of machines @@ -95,12 +76,9 @@ def __init__(self, case, env_paras, data_source='case'): # load instance num_data = 8 # The amount of data extracted from instance tensors = [[] for _ in range(num_data)] - self.simulation_envs: list[JobShop] = [] # variable for keeping track of scheduling_environment envs self.num_opes = 0 lines = [] - self.data_source = data_source - - if data_source=='case': # Generate instances through generators + if data_source == 'case': # Generate instances through generators for i in range(self.batch_size): lines.append(case.get_case(i)[0]) # Generate an instance and save it num_jobs, num_mas, num_opes = nums_detec(lines[i]) @@ -115,13 +93,9 @@ def __init__(self, case, env_paras, data_source='case'): self.num_opes = max(self.num_opes, num_opes) # load feats for i in range(self.batch_size): - if data_source == 'case': - load_data = load_fjs_case(lines[i], self.num_mas, self.num_opes) - else: - load_data, env = load_fjs(case[i], self.num_mas, self.num_opes, self.num_jobs) - self.simulation_envs.append(env) + load_data = load_feats_from_case(lines[i], num_mas, self.num_opes) for j in range(num_data): - tensors[j].append(load_data[j].to(self.device)) + tensors[j].append(load_data[j]) # dynamic feats # shape: (batch_size, num_opes, num_mas) @@ -176,8 +150,7 @@ def __init__(self, case, env_paras, data_source='case'): feat_opes_batch[:, 3, :] = convert_feat_job_2_ope(self.nums_ope_batch, self.opes_appertain_batch) feat_opes_batch[:, 5, :] = torch.bmm(feat_opes_batch[:, 2, :].unsqueeze(1), self.cal_cumul_adj_batch).squeeze() - end_time_batch = (feat_opes_batch[:, 5, :] + - feat_opes_batch[:, 2, :]).gather(1, self.end_ope_biases_batch) + end_time_batch = (feat_opes_batch[:, 5, :] + feat_opes_batch[:, 2, :]).gather(1, self.end_ope_biases_batch) feat_opes_batch[:, 4, :] = convert_feat_job_2_ope(end_time_batch, self.opes_appertain_batch) feat_mas_batch[:, 0, :] = torch.count_nonzero(self.ope_ma_adj_batch, dim=1) self.feat_opes_batch = feat_opes_batch @@ -242,16 +215,6 @@ def step(self, actions): jobs = actions[2, :] self.N += 1 - if self.data_source != 'case': - for index in range(self.batch_size): - operation_ix = opes[index].item() - machine_ix = mas[index].item() - job_ix = jobs[index].item() - env = self.simulation_envs[index] - operation = env.operations[operation_ix] - duration = operation.processing_times[machine_ix] - env.schedule_operation_on_machine_backfilling(operation, machine_ix, duration) - # Removed unselected O-M arcs of the scheduled operations remain_ope_ma_adj = torch.zeros(size=(self.batch_size, self.num_mas), dtype=torch.int64) remain_ope_ma_adj[self.batch_idxes, mas] = 1 @@ -281,11 +244,9 @@ def step(self, actions): start_times = self.feat_opes_batch[self.batch_idxes, 5, :] * is_scheduled # real start time of scheduled opes un_scheduled = 1 - is_scheduled # unscheduled opes estimate_times = torch.bmm((start_times + mean_proc_time).unsqueeze(1), - self.cal_cumul_adj_batch[self.batch_idxes, :, :]).squeeze() \ - * un_scheduled # estimate start time of unscheduled opes + self.cal_cumul_adj_batch[self.batch_idxes, :, :]).squeeze() * un_scheduled # estimate start time of unscheduled opes self.feat_opes_batch[self.batch_idxes, 5, :] = start_times + estimate_times - end_time_batch = (self.feat_opes_batch[self.batch_idxes, 5, :] + - self.feat_opes_batch[self.batch_idxes, 2, :]).gather(1, self.end_ope_biases_batch[ + end_time_batch = (self.feat_opes_batch[self.batch_idxes, 5, :] + self.feat_opes_batch[self.batch_idxes, 2, :]).gather(1, self.end_ope_biases_batch[ self.batch_idxes, :]) self.feat_opes_batch[self.batch_idxes, 4, :] = convert_feat_job_2_ope(end_time_batch, self.opes_appertain_batch[ self.batch_idxes, :]) @@ -340,7 +301,7 @@ def step(self, actions): self.ope_ma_adj_batch, self.mask_job_procing_batch, self.mask_job_finish_batch, self.mask_ma_procing_batch, self.ope_step_batch, self.time) - return self.state, self.reward_batch, self.done_batch + return self.state, self.reward_batch, self.done_batch, None def if_no_eligible(self): """ @@ -501,97 +462,3 @@ def validate_gantt(self): def close(self): pass - - -class CaseGenerator: - """ - FJSP instance generator - """ - def __init__(self, job_init, num_mas, opes_per_job_min, opes_per_job_max, nums_ope=None, path='../data/', - flag_same_opes=True, flag_doc=False): - if nums_ope is None: - nums_ope = [] - self.flag_doc = flag_doc # Whether save the instance to a file - self.flag_same_opes = flag_same_opes - self.nums_ope = nums_ope - self.path = path # Instance save path (relative path) - self.job_init = job_init - self.num_mas = num_mas - - self.mas_per_ope_min = 1 # The minimum number of machines that can process an operation - self.mas_per_ope_max = num_mas - self.opes_per_job_min = opes_per_job_min # The minimum number of operations for a job - self.opes_per_job_max = opes_per_job_max - self.proctime_per_ope_min = 1 # Minimum average processing time - self.proctime_per_ope_max = 20 - self.proctime_dev = 0.2 - - def get_case(self, idx=0): - """ - Generate FJSP instance - :param idx: The instance number - """ - self.num_jobs = self.job_init - if not self.flag_same_opes: - self.nums_ope = [random.randint(self.opes_per_job_min, self.opes_per_job_max) for _ in range(self.num_jobs)] - self.num_opes = sum(self.nums_ope) - self.nums_option = [random.randint(self.mas_per_ope_min, self.mas_per_ope_max) for _ in range(self.num_opes)] - self.num_options = sum(self.nums_option) - self.ope_ma = [] - for val in self.nums_option: - self.ope_ma = self.ope_ma + sorted(random.sample(range(1, self.num_mas+1), val)) - self.proc_time = [] - self.proc_times_mean = [random.randint(self.proctime_per_ope_min, self.proctime_per_ope_max) for _ in range(self.num_opes)] - for i in range(len(self.nums_option)): - low_bound = max(self.proctime_per_ope_min,round(self.proc_times_mean[i]*(1-self.proctime_dev))) - high_bound = min(self.proctime_per_ope_max,round(self.proc_times_mean[i]*(1+self.proctime_dev))) - proc_time_ope = [random.randint(low_bound, high_bound) for _ in range(self.nums_option[i])] - self.proc_time = self.proc_time + proc_time_ope - self.num_ope_biass = [sum(self.nums_ope[0:i]) for i in range(self.num_jobs)] - self.num_ma_biass = [sum(self.nums_option[0:i]) for i in range(self.num_opes)] - line0 = '{0}\t{1}\t{2}\n'.format(self.num_jobs, self.num_mas, self.num_options / self.num_opes) - lines = [] - lines_doc = [] - lines.append(line0) - lines_doc.append('{0}\t{1}\t{2}'.format(self.num_jobs, self.num_mas, self.num_options / self.num_opes)) - for i in range(self.num_jobs): - flag = 0 - flag_time = 0 - flag_new_ope = 1 - idx_ope = -1 - idx_ma = 0 - line = [] - option_max = sum(self.nums_option[self.num_ope_biass[i]:(self.num_ope_biass[i]+self.nums_ope[i])]) - idx_option = 0 - while True: - if flag == 0: - line.append(self.nums_ope[i]) - flag += 1 - elif flag == flag_new_ope: - idx_ope += 1 - idx_ma = 0 - flag_new_ope += self.nums_option[self.num_ope_biass[i]+idx_ope] * 2 + 1 - line.append(self.nums_option[self.num_ope_biass[i]+idx_ope]) - flag += 1 - elif flag_time == 0: - line.append(self.ope_ma[self.num_ma_biass[self.num_ope_biass[i]+idx_ope] + idx_ma]) - flag += 1 - flag_time = 1 - else: - line.append(self.proc_time[self.num_ma_biass[self.num_ope_biass[i]+idx_ope] + idx_ma]) - flag += 1 - flag_time = 0 - idx_option += 1 - idx_ma += 1 - if idx_option == option_max: - str_line = " ".join([str(val) for val in line]) - lines.append(str_line + '\n') - lines_doc.append(str_line) - break - lines.append('\n') - if self.flag_doc: - doc = open(self.path + '{0}j_{1}m_{2}.fjs'.format(self.num_jobs, self.num_mas, str.zfill(str(idx+1),3)),'a') - for i in range(len(lines_doc)): - print(lines_doc[i], file=doc) - doc.close() - return lines, self.num_jobs, self.num_jobs diff --git a/solutions/FJSP_DRL/hgnn.py b/solution_methods/FJSP_DRL/hgnn.py similarity index 100% rename from solutions/FJSP_DRL/hgnn.py rename to solution_methods/FJSP_DRL/hgnn.py index 5ac6086..5a5cff4 100644 --- a/solutions/FJSP_DRL/hgnn.py +++ b/solution_methods/FJSP_DRL/hgnn.py @@ -7,9 +7,9 @@ # Paper URL: https://ieeexplore.ieee.org/document/9826438 import torch +import torch.nn.functional as F from torch import nn from torch.nn import Identity -import torch.nn.functional as F class GATedge(nn.Module): diff --git a/solutions/FJSP_DRL/load_data.py b/solution_methods/FJSP_DRL/load_data.py similarity index 88% rename from solutions/FJSP_DRL/load_data.py rename to solution_methods/FJSP_DRL/load_data.py index 98b8a34..1df475f 100644 --- a/solutions/FJSP_DRL/load_data.py +++ b/solution_methods/FJSP_DRL/load_data.py @@ -7,21 +7,21 @@ # Paper URL: https://ieeexplore.ieee.org/document/9826438 import sys +from pathlib import Path -import torch import numpy as np -from pathlib import Path +import torch from scheduling_environment.jobShop import JobShop -from solutions.helper_functions import load_job_shop_env from scheduling_environment.operation import Operation +from solution_methods.helper_functions import load_job_shop_env # Add the base path to the Python module search path base_path = Path(__file__).resolve().parents[2] sys.path.append(str(base_path)) -def load_fjs_case(lines, num_mas, num_opes): +def load_feats_from_case(lines, num_mas, num_opes): """ Load the local FJSP instance. """ @@ -38,7 +38,7 @@ def load_fjs_case(lines, num_mas, num_opes): if flag == 0: flag += 1 # last line - elif line is "\n": + elif line == "\n": break # other else: @@ -58,19 +58,17 @@ def load_fjs_case(lines, num_mas, num_opes): torch.tensor(nums_ope).int(), matrix_cal_cumul -def load_fjs(path, num_mas, num_opes, num_jobs) -> tuple[tuple[ - torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor], JobShop]: +def load_fjs(path, num_mas, num_opes, num_jobs): """ Load the local FJSP instance. """ jobShopEnv = load_job_shop_env(path, from_absolute_path=True) - drl_tensors = load_fjs_from_sim(jobShopEnv, num_mas, num_opes) + drl_tensors = load_feats_from_sim(jobShopEnv, num_mas, num_opes) return drl_tensors, jobShopEnv -def load_fjs_from_sim(jobShopEnv: JobShop, num_mas, num_opes) -> tuple[ - torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: +def load_feats_from_sim(jobShopEnv: JobShop, num_mas, num_opes): """convert scheduling_environment environment to DRL environment""" matrix_proc_time = torch.zeros(size=(num_opes, num_mas)) matrix_ope_ma_adj = torch.zeros(size=(num_opes, num_mas)).int() @@ -98,8 +96,8 @@ def load_fjs_from_sim(jobShopEnv: JobShop, num_mas, num_opes) -> tuple[ predecessor: Operation matrix_pre_proc[predecessor.operation_id][operation.operation_id] = True - return matrix_proc_time, matrix_ope_ma_adj, matrix_pre_proc, matrix_pre_proc.t(), \ - opes_appertain, num_ope_biases, nums_ope, matrix_cal_cumul + return matrix_proc_time, matrix_ope_ma_adj, matrix_pre_proc, matrix_pre_proc.t(), opes_appertain, num_ope_biases, \ + nums_ope, matrix_cal_cumul def nums_detec(lines): @@ -155,4 +153,4 @@ def edge_detec(line, num_ope_bias, matrix_proc_time, matrix_pre_proc, matrix_cal matrix_proc_time[idx_ope + num_ope_bias][mac] = x flag += 1 flag_time = 0 - return num_ope \ No newline at end of file + return num_ope diff --git a/solutions/FJSP_DRL/mlp.py b/solution_methods/FJSP_DRL/mlp.py similarity index 78% rename from solutions/FJSP_DRL/mlp.py rename to solution_methods/FJSP_DRL/mlp.py index 863abb1..b94eb8f 100644 --- a/solutions/FJSP_DRL/mlp.py +++ b/solution_methods/FJSP_DRL/mlp.py @@ -14,11 +14,11 @@ class MLP(nn.Module): def __init__(self, num_layers, input_dim, hidden_dim, output_dim): """ - num_layers: number of layers in the neural networks (EXCLUDING the input layer). If num_layers=1, this reduces to linear model. - input_dim: dimensionality of input features - hidden_dim: dimensionality of hidden units at ALL layers - output_dim: number of classes for prediction - device: which device to use + num_layers: number of layers in the neural networks (EXCLUDING the input layer). If num_layers=1, this reduces to linear model. + input_dim: dimensionality of input features + hidden_dim: dimensionality of hidden units at ALL layers + output_dim: number of classes for prediction + device: which device to use """ super(MLP, self).__init__() @@ -60,11 +60,11 @@ def forward(self, x): class MLPActor(nn.Module): def __init__(self, num_layers, input_dim, hidden_dim, output_dim): """ - num_layers: number of layers in the neural networks (EXCLUDING the input layer). If num_layers=1, this reduces to linear model. - input_dim: dimensionality of input features - hidden_dim: dimensionality of hidden units at ALL layers - output_dim: number of classes for prediction - device: which device to use + num_layers: number of layers in the neural networks (EXCLUDING the input layer). If num_layers=1, this reduces to linear model. + input_dim: dimensionality of input features + hidden_dim: dimensionality of hidden units at ALL layers + output_dim: number of classes for prediction + device: which device to use """ super(MLPActor, self).__init__() @@ -81,18 +81,18 @@ def __init__(self, num_layers, input_dim, hidden_dim, output_dim): # Multi-layer model self.linear_or_not = False self.linears = torch.nn.ModuleList() - ''' + """ self.batch_norms = torch.nn.ModuleList() - ''' + """ self.linears.append(nn.Linear(input_dim, hidden_dim)) for layer in range(num_layers - 2): self.linears.append(nn.Linear(hidden_dim, hidden_dim)) self.linears.append(nn.Linear(hidden_dim, output_dim)) - ''' + """ for layer in range(num_layers - 1): self.batch_norms.append(nn.BatchNorm1d((hidden_dim))) - ''' + """ def forward(self, x): if self.linear_or_not: @@ -102,22 +102,21 @@ def forward(self, x): # If MLP h = x for layer in range(self.num_layers - 1): - ''' + """ h = F.relu(self.batch_norms[layer](self.linears[layer](h))) - ''' + """ h = torch.tanh(self.linears[layer](h)) - # h = F.relu(self.linears[layer](h)) return self.linears[self.num_layers - 1](h) class MLPCritic(nn.Module): def __init__(self, num_layers, input_dim, hidden_dim, output_dim): """ - num_layers: number of layers in the neural networks (EXCLUDING the input layer). If num_layers=1, this reduces to linear model. - input_dim: dimensionality of input features - hidden_dim: dimensionality of hidden units at ALL layers - output_dim: number of classes for prediction - device: which device to use + num_layers: number of layers in the neural networks (EXCLUDING the input layer). If num_layers=1, this reduces to linear model. + input_dim: dimensionality of input features + hidden_dim: dimensionality of hidden units at ALL layers + output_dim: number of classes for prediction + device: which device to use """ super(MLPCritic, self).__init__() @@ -134,18 +133,18 @@ def __init__(self, num_layers, input_dim, hidden_dim, output_dim): # Multi-layer model self.linear_or_not = False self.linears = torch.nn.ModuleList() - ''' + """ self.batch_norms = torch.nn.ModuleList() - ''' + """ self.linears.append(nn.Linear(input_dim, hidden_dim)) for layer in range(num_layers - 2): self.linears.append(nn.Linear(hidden_dim, hidden_dim)) self.linears.append(nn.Linear(hidden_dim, output_dim)) - ''' + """ for layer in range(num_layers - 1): self.batch_norms.append(nn.BatchNorm1d((hidden_dim))) - ''' + """ def forward(self, x): if self.linear_or_not: @@ -155,9 +154,9 @@ def forward(self, x): # If MLP h = x for layer in range(self.num_layers - 1): - ''' + """ h = F.relu(self.batch_norms[layer](self.linears[layer](h))) - ''' + """ h = torch.tanh(self.linears[layer](h)) # h = F.relu(self.linears[layer](h)) return self.linears[self.num_layers - 1](h) diff --git a/solutions/FJSP_DRL/models/song_10_5.pt b/solution_methods/FJSP_DRL/save/train_20240314_192906/song_10_5.pt similarity index 100% rename from solutions/FJSP_DRL/models/song_10_5.pt rename to solution_methods/FJSP_DRL/save/train_20240314_192906/song_10_5.pt diff --git a/solution_methods/FJSP_DRL/training.py b/solution_methods/FJSP_DRL/training.py new file mode 100644 index 0000000..63c79a3 --- /dev/null +++ b/solution_methods/FJSP_DRL/training.py @@ -0,0 +1,173 @@ +import argparse +import copy +import logging +import os +import random +import sys +import time +from collections import deque +from pathlib import Path + +import numpy as np +import torch +from visdom import Visdom + +import solution_methods.FJSP_DRL.PPO as PPO_model +from solution_methods.FJSP_DRL.case_generator import CaseGenerator +from solution_methods.FJSP_DRL.env_training import FJSPEnv_training +from solution_methods.FJSP_DRL.validate import get_validate_env, validate +from solution_methods.helper_functions import load_parameters + +# Add the base path to the Python module search path +base_path = Path(__file__).resolve().parents[2] +sys.path.append(str(base_path)) + +# import FJSP parameters +PARAM_FILE = str(base_path) + "/configs/FJSP_DRL.toml" + + +def initialize_device(parameters: dict) -> torch.device: + device_str = "cpu" + if parameters["test_parameters"]["device"] == "cuda": + device_str = "cuda:0" if torch.cuda.is_available() else "cpu" + return torch.device(device_str) + + +def train_FJSP_DRL(param_file: str = PARAM_FILE): + try: + parameters = load_parameters(param_file) + except FileNotFoundError: + logging.error(f"Parameter file {param_file} not found.") + return + + device = initialize_device(parameters) + + # Configure PyTorch's default device + torch.set_default_tensor_type('torch.cuda.FloatTensor' if device.type == 'cuda' else 'torch.FloatTensor') + if device.type == 'cuda': + torch.cuda.set_device(device) + seed = 1 + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + random.seed(seed) + + # Extract parameters + env_parameters = parameters["env_parameters"] + model_parameters = parameters["model_parameters"] + train_parameters = parameters["train_parameters"] + + env_validation_parameters = copy.deepcopy(env_parameters) + env_validation_parameters["batch_size"] = env_parameters["valid_batch_size"] + + model_parameters["actor_in_dim"] = model_parameters["out_size_ma"] * 2 + model_parameters["out_size_ope"] * 2 + model_parameters["critic_in_dim"] = model_parameters["out_size_ma"] + model_parameters["out_size_ope"] + + num_jobs = env_parameters["num_jobs"] + num_machines = env_parameters["num_mas"] + opes_per_job_min = int(num_machines * 0.8) + opes_per_job_max = int(num_machines * 1.2) + print(num_jobs, num_machines) + + memories = PPO_model.Memory() + model = PPO_model.PPO(model_parameters, train_parameters, num_envs=env_parameters["batch_size"]) + + env_valid = get_validate_env(env_validation_parameters, train_parameters) # Create an environment for validation + maxlen = 1 # Save the best model + best_models = deque() + makespan_best = float("inf") + + # Use visdom to visualize the training process + is_viz = train_parameters["viz"] + if is_viz: + viz = Visdom(env=train_parameters["viz_name"]) + + # Generate data files and fill in the header + str_time = time.strftime("%Y%m%d_%H%M%S", time.localtime(time.time())) + save_path = "./save/train_{0}".format(str_time) + os.makedirs(save_path) + + valid_results = [] + valid_results_100 = [] + + # Training part + env_training = None + for i in range(1, train_parameters["max_iterations"] + 1): + if (i - 1) % train_parameters["parallel_iter"] == 0: + nums_ope = [random.randint(opes_per_job_min, opes_per_job_max) for _ in range(num_jobs)] + case = CaseGenerator(num_jobs, num_machines, opes_per_job_min, opes_per_job_max, nums_ope=nums_ope) + env_training = FJSPEnv_training(case=case, env_paras=env_parameters) + + # Get state and completion signal + env_training.reset() + state = env_training.state + done = False + dones = env_training.done_batch + last_time = time.time() + + # Schedule in parallel + while ~done: + with torch.no_grad(): + actions = model.policy_old.act(state, memories, dones) + state, rewards, dones, _ = env_training.step(actions) + done = dones.all() + memories.rewards.append(rewards) + memories.is_terminals.append(dones) + # gpu_tracker.track() # Used to monitor memory (of gpu) + print("spend_time: ", time.time() - last_time) + + # Verify the solution + gantt_result = env_training.validate_gantt()[0] + if not gantt_result: + print("Scheduling Error!!!!!!") + # print("Scheduling Finish") + env_training.reset() + + # if iter mod x = 0 then update the policy (x = 1 in paper) + if i % train_parameters["update_timestep"] == 0: + loss, reward = model.update(memories, env_parameters, train_parameters) + print("reward: ", "%.3f" % reward, "; loss: ", "%.3f" % loss) + memories.clear_memory() + + if is_viz: + viz.line(X=np.array([i]), Y=np.array([reward]), + win='window{}'.format(0), update='append', opts=dict(title='reward of envs')) + viz.line(X=np.array([i]), Y=np.array([loss]), + win='window{}'.format(1), update='append', opts=dict(title='loss of envs')) + + # if iter mod x = 0 then validate the policy (x = 10 in paper) + if i % train_parameters["save_timestep"] == 0: + print("\nStart validating") + # Record the average results and the results on each instance + vali_result, vali_result_100 = validate(env_validation_parameters, env_valid, model.policy_old) + valid_results.append(vali_result.item()) + valid_results_100.append(vali_result_100) + + # Save the best model + if vali_result < makespan_best: + makespan_best = vali_result + if len(best_models) == maxlen: + delete_file = best_models.popleft() + os.remove(delete_file) + save_file = "{0}/save_best_{1}_{2}_{3}.pt".format(save_path, num_jobs, num_machines, i) + best_models.append(save_file) + torch.save(model.policy.state_dict(), save_file) + + if is_viz: + viz.line( + X=np.array([i]), Y=np.array([vali_result.item()]), + win="window{}".format(2), update="append", opts=dict(title="makespan of valid")) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="train FJSP_DRL") + parser.add_argument( + "config_file", + metavar="-f", + type=str, + nargs="?", + default=PARAM_FILE, + help="path to config file", + ) + + args = parser.parse_args() + train_FJSP_DRL(param_file=args.config_file) diff --git a/solutions/FJSP_DRL/validate.py b/solution_methods/FJSP_DRL/validate.py similarity index 54% rename from solutions/FJSP_DRL/validate.py rename to solution_methods/FJSP_DRL/validate.py index a60a7d9..93ccff1 100644 --- a/solutions/FJSP_DRL/validate.py +++ b/solution_methods/FJSP_DRL/validate.py @@ -6,15 +6,15 @@ # Presented in IEEE Transactions on Industrial Informatics, 2023. # Paper URL: https://ieeexplore.ieee.org/document/9826438 -import time import copy import os +import time +from pathlib import Path import torch -from pathlib import Path -import gymnasium as gym -import PPO_model +import solution_methods.FJSP_DRL.PPO as PPO_model +from solution_methods.FJSP_DRL.env_training import FJSPEnv_training base_path = Path(__file__).resolve().parents[2] @@ -23,35 +23,40 @@ def get_validate_env(env_paras, train_paras): """ Generate and return the validation environment from the validation set () """ - file_path = str(base_path) + '/data/' + train_paras["validation_folder"] + file_path = str(base_path) + "/data/" + train_paras["validation_folder"] valid_data_files = os.listdir(file_path) for i in range(len(valid_data_files)): valid_data_files[i] = file_path + valid_data_files[i] - env = gym.make('fjsp-v0', case=valid_data_files, env_paras=env_paras, data_source='file') + env = FJSPEnv_training(case=valid_data_files, env_paras=env_paras, data_source="file") return env -def validate(env_paras, env, model_policy): +def validate(env_paras, env_validate, model_policy): """ Validate the policy during training, and the process is similar to test """ start = time.time() batch_size = env_paras["batch_size"] memory = PPO_model.Memory() - print('There are {0} dev instances.'.format(batch_size)) # validation set is also called development set - state = env.state + print( + "There are {0} dev instances.".format(batch_size) + ) # validation set is also called development set + env_validate.reset() + state = env_validate.state done = False - dones = env.done_batch + dones = env_validate.done_batch while ~done: with torch.no_grad(): - actions = model_policy.act(state, memory, dones, flag_sample=False, flag_train=False) - state, rewards, dones = env.step(actions) + actions = model_policy.act( + state, memory, dones, flag_sample=False, flag_train=False + ) + state, rewards, dones, _ = env_validate.step(actions) done = dones.all() - gantt_result = env.validate_gantt()[0] + gantt_result = env_validate.validate_gantt()[0] if not gantt_result: print("Scheduling Error!") - makespan = copy.deepcopy(env.makespan_batch.mean()) - makespan_batch = copy.deepcopy(env.makespan_batch) - env.reset() - print('validating time: ', time.time() - start, '\n') + makespan = copy.deepcopy(env_validate.makespan_batch.mean()) + makespan_batch = copy.deepcopy(env_validate.makespan_batch) + env_validate.reset() + print("validating time: ", time.time() - start, "\n") return makespan, makespan_batch diff --git a/solutions/MILP/FJSPSDSTmodel.py b/solution_methods/MILP/FJSPSDSTmodel.py similarity index 99% rename from solutions/MILP/FJSPSDSTmodel.py rename to solution_methods/MILP/FJSPSDSTmodel.py index f5da63f..304bb55 100644 --- a/solutions/MILP/FJSPSDSTmodel.py +++ b/solution_methods/MILP/FJSPSDSTmodel.py @@ -4,7 +4,7 @@ # Presented in European Journal of Operational Research, 2018. # Paper URL: https://www.sciencedirect.com/science/article/pii/S037722171730752X -from gurobipy import Model, GRB, quicksum +from gurobipy import GRB, Model, quicksum def parse_file(filename): diff --git a/solutions/MILP/FJSPmodel.py b/solution_methods/MILP/FJSPmodel.py similarity index 98% rename from solutions/MILP/FJSPmodel.py rename to solution_methods/MILP/FJSPmodel.py index dd0c36d..6e495bf 100644 --- a/solutions/MILP/FJSPmodel.py +++ b/solution_methods/MILP/FJSPmodel.py @@ -4,8 +4,7 @@ # Presented in International Journal of Production Research, 2013. # Paper URL: https://www.tandfonline.com/doi/full/10.1080/00207543.2013.827806 - -from gurobipy import Model, GRB, quicksum +from gurobipy import GRB, Model, quicksum def parse_file(filename): @@ -64,7 +63,7 @@ def parse_file(filename): 'operations_per_job': operations_per_job, 'machine_allocations': machine_allocations, 'operations_times': operations_times, - 'largeM': largeM, # + 'largeM': largeM, "sdsts": sdsts } diff --git a/solution_methods/MILP/JSPmodel.py b/solution_methods/MILP/JSPmodel.py new file mode 100644 index 0000000..31d4f62 --- /dev/null +++ b/solution_methods/MILP/JSPmodel.py @@ -0,0 +1,116 @@ +# Code based on the paper: "On the job-shop scheduling problem" +# by A. S. Manne, Presented in Operations Research, 1960. +# Paper URL: https://www.jstor.org/stable/167204 + +from gurobipy import GRB, Model + + +def parse_file(filename): + # Initialize variables + machine_allocations = {} + operations_times = {} + total_op_nr = 0 + + with open("./data/" + filename, 'r') as f: + # Extract header data + number_operations, number_machines = map(float, f.readline().split()) + number_jobs = int(number_operations) + number_machines = int(number_machines) + operations_per_job = {j: [] for j in range(1, number_jobs + 1)} + + # Process job operations data + for i in range(number_jobs): + operation_data = list(map(int, f.readline().split())) + index, operation_id = 0, 0 + while index < len(operation_data): + total_op_nr += 1 + + job_machines = operation_data[index] + job_processingtime = operation_data[index + 1] + machine_allocations[(i + 1, operation_id + 1)] = job_machines + operations_times[(i + 1, operation_id + 1, job_machines)] = job_processingtime + operations_per_job[i+1].append(operation_id + 1) + + operation_id += 1 + index += 2 + + # Calculate derived values + jobs = list(range(1, number_jobs + 1)) + machines = list(range(0, number_machines)) + # calculate upper bound + largeM = sum([processing_time for operation,processing_time in operations_times.items()]) + + # Return parsed data + return { + 'number_jobs': number_jobs, + 'number_machines': number_machines, + 'jobs': jobs, + 'machines': machines, + 'operations_per_job': operations_per_job, + 'machine_allocations': machine_allocations, + 'operations_times': operations_times, + 'largeM': largeM, + } + + +def jsp_milp(instance_data, time_limit): + # Extracting the data + jobs = instance_data['jobs'] + operations_per_job = instance_data['operations_per_job'] + machine_allocations = instance_data['machine_allocations'] + operations_times = instance_data['operations_times'] + largeM = instance_data['largeM'] + model = Model("JSP_MILP") + + # Decision variables + x = {} + z = {} + + for j in jobs: + for l in operations_per_job[j]: + i = machine_allocations[(j, l)] + x[(j, l, i)] = model.addVar(vtype=GRB.INTEGER, name=f"x_{j}_{l}_{i}", lb=0) + for h in jobs: + if h > j: + for k in operations_per_job[h]: + if machine_allocations[(h, k)] == i: + z[(j, l, i, h, k)] = model.addVar(vtype=GRB.BINARY, name=f"z_{j}_{l}_{i}_{h}_{k}") + + # Objective function (1) + cmax = model.addVar(vtype=GRB.CONTINUOUS, name="Cmax") + model.setObjective(cmax, GRB.MINIMIZE) + + # Constraints + + # Operation start time constraints (2) + for j in jobs: + for l in operations_per_job[j]: + model.addConstr(x[(j, l, machine_allocations[(j, l)])] >= 0) + + # Precedence constraints (3) + for j in jobs: + for l in operations_per_job[j][:-1]: + model.addConstr(x[(j, l+1, machine_allocations[(j, l+1)])] >= + x[(j, l, machine_allocations[(j, l)])] + operations_times[(j, l, machine_allocations[(j, l)])]) + + # Disjunctive constraints (no two jobs can be scheduled on the same machine at the same time) (4 & 5) + for j in jobs: + for l in operations_per_job[j]: + for h in jobs: + if h > j: + for k in operations_per_job[h]: + if machine_allocations[(h, k)] == machine_allocations[(j, l)]: + model.addConstr(x[(h, k, machine_allocations[(h, k)])] + operations_times[(h, k, machine_allocations[(h, k)])] <= + x[(j, l, machine_allocations[(j, l)])] + largeM * z[(j, l, machine_allocations[(j, l)], h, k)]) + model.addConstr(x[(j, l, machine_allocations[(j, l)])] + operations_times[(j, l, machine_allocations[(j, l)])] <= + x[(h, k, machine_allocations[(h, k)])] + largeM * (1 - z[(j, l, machine_allocations[(j, l)], h, k)])) + + # Capture objective function (6) + for j in jobs: + model.addConstr( + cmax >= x[j, max(operations_per_job[j]), machine_allocations[j, max(operations_per_job[j])]] + + operations_times[j, max(operations_per_job[j]), machine_allocations[j, max(operations_per_job[j])]]) + + model.params.TimeLimit = time_limit + + return model \ No newline at end of file diff --git a/solutions/__init__.py b/solution_methods/__init__.py similarity index 100% rename from solutions/__init__.py rename to solution_methods/__init__.py diff --git a/solutions/dispatching_rules/helper_functions.py b/solution_methods/dispatching_rules/helper_functions.py similarity index 100% rename from solutions/dispatching_rules/helper_functions.py rename to solution_methods/dispatching_rules/helper_functions.py diff --git a/solutions/genetic_algorithm/operators.py b/solution_methods/genetic_algorithm/operators.py similarity index 96% rename from solutions/genetic_algorithm/operators.py rename to solution_methods/genetic_algorithm/operators.py index 9475e5c..205643b 100644 --- a/solutions/genetic_algorithm/operators.py +++ b/solution_methods/genetic_algorithm/operators.py @@ -1,12 +1,13 @@ -import time import random +import time import numpy as np from scheduling_environment.jobShop import JobShop from scheduling_environment.operation import Operation -from solutions.heuristics_scheduler.heuristics import global_selection_scheduler, local_selection_scheduler, random_scheduler -from solutions.helper_functions import update_operations_available_for_scheduling +from solution_methods.helper_functions import update_operations_available_for_scheduling +from solution_methods.heuristics_scheduler.heuristics import ( + global_selection_scheduler, local_selection_scheduler, random_scheduler) def select_next_operation_from_job(jobShopEnv: JobShop, job_id) -> Operation: diff --git a/solutions/helper_functions.py b/solution_methods/helper_functions.py similarity index 97% rename from solutions/helper_functions.py rename to solution_methods/helper_functions.py index 15396dc..ff3d1b0 100644 --- a/solutions/helper_functions.py +++ b/solution_methods/helper_functions.py @@ -1,10 +1,10 @@ import os -import tomli import pandas as pd +import tomli +from data_parsers import parser_fajsp, parser_fjsp, parser_fjsp_sdst, parser_jsp_fsp from scheduling_environment.jobShop import JobShop -from data_parsers import parser_fajsp, parser_fjsp, parser_jsp_fsp, parser_fjsp_sdst def load_parameters(config_toml): diff --git a/solutions/heuristics_scheduler/heuristics.py b/solution_methods/heuristics_scheduler/heuristics.py similarity index 98% rename from solutions/heuristics_scheduler/heuristics.py rename to solution_methods/heuristics_scheduler/heuristics.py index c837541..12937de 100644 --- a/solutions/heuristics_scheduler/heuristics.py +++ b/solution_methods/heuristics_scheduler/heuristics.py @@ -2,8 +2,8 @@ import numpy as np -from solutions.helper_functions import update_operations_available_for_scheduling from scheduling_environment.jobShop import JobShop +from solution_methods.helper_functions import update_operations_available_for_scheduling def random_scheduler(jobShop: JobShop) -> JobShop: diff --git a/solution_methods/or_tools/FJSPmodel.py b/solution_methods/or_tools/FJSPmodel.py new file mode 100644 index 0000000..004858e --- /dev/null +++ b/solution_methods/or_tools/FJSPmodel.py @@ -0,0 +1,254 @@ +""" +This file contains the OR-Tools model for the Flexible Job Shop Problem (FJSP). +This code has been adapted from the OR-Tools example for the FJSP, which can be found at: +https://github.com/google/or-tools/blob/stable/examples/python/flexible_job_shop_sat.py +""" + +import collections + +from ortools.sat.python import cp_model + + +def parse_file_jsp(filename: str) -> dict: + """ + Parses a file containing job shop scheduling data and returns a dictionary + with the number of jobs, number of machines, and the job operations. + + Args: + filename (str): The name of the file to parse. + + Returns: + dict: A dictionary containing the parsed data with the following keys: + - "num_jobs": The number of jobs. + - "num_machines": The number of machines. + - "jobs": A list of job operations, where each job operation is a list + with a single tuple representing the machine and processing time. + + """ + with open("./data/" + filename, "r") as f: + num_jobs, num_machines = tuple(map(int, f.readline().strip().split())) + jobs = [] + for _ in range(num_jobs): + job_operations = [] + data_line = list(map(int, f.readline().split())) + job_operations = [ + [(data_line[i + 1], data_line[i])] for i in range(0, len(data_line), 2) + ] + jobs.append(job_operations) + + return {"num_jobs": num_jobs, "num_machines": num_machines, "jobs": jobs} + + +def parse_file_fjsp(filename: str) -> dict: + """ + Parses a file containing flexible job shop scheduling data and returns a + dictionary with the number of jobs, number of machines, and the job operations. + + Args: + filename (str): The name of the file to parse. + + Returns: + dict: A dictionary containing the following keys: + - "num_jobs" (int): The number of jobs. + - "num_machines" (int): The number of machines. + - "jobs" (list): A list of job operations. Each job operation is a list + of tuples, where each tuple contains the processing time and machine + index for an operation. + """ + with open("./data/" + filename, "r") as f: + num_jobs, num_machines = tuple(map(int, f.readline().strip().split()[:2])) + jobs = [] + for _ in range(num_jobs): + job_operations = [] + operation_data = list(map(int, f.readline().split())) + index = 1 + while index < len(operation_data): + # Extract machine and processing time data for each operation + machines_for_task = operation_data[index] + # below x - 1 to go from 1 as lowest index to 0 as lowest index + job_machines = list( + map( + lambda x: x - 1, + operation_data[ + index + 1 : index + 1 + machines_for_task * 2 : 2 + ], + ) + ) + job_processingtimes = operation_data[ + index + 2 : index + 2 + machines_for_task * 2 : 2 + ] + operation_info = list(zip(job_processingtimes, job_machines)) + index += machines_for_task * 2 + 1 + job_operations.append(operation_info) + jobs.append(job_operations) + return {"num_jobs": num_jobs, "num_machines": num_machines, "jobs": jobs} + + +class SolutionPrinter(cp_model.CpSolverSolutionCallback): + """Print intermediate solution_methods.""" + + def __init__(self): + cp_model.CpSolverSolutionCallback.__init__(self) + self.__solution_count = 0 + + def on_solution_callback(self): + """Called at each new solution.""" + print( + "Solution %i, time = %f s, objective = %i" + % (self.__solution_count, self.WallTime(), self.ObjectiveValue()) + ) + self.__solution_count += 1 + + def solution_count(self): + return self.__solution_count + + +def fjsp_or_tools_model(data: dict) -> tuple[cp_model.CpModel, dict]: + """ + Creates a flexible job shop scheduling model using the OR-Tools library. + + Args: + data (dict): A dictionary containing the input data for the flexible job shop scheduling problem. + The dictionary should have the following keys: + - "num_jobs" (int): The number of jobs in the problem. + - "num_machines" (int): The number of machines in the problem. + - "jobs" (list): A list of jobs, where each job is represented as a list of tasks. + Each task is represented as a list of alternatives, where each alternative is a tuple + containing the duration of the task and the machine on which it can be executed. + + Returns: + tuple[cp_model.CpModel, dict]: A tuple containing the flexible job shop scheduling model and a dictionary + with the variables and intervals created during the model construction. The dictionary has the + following keys: + - "starts" (dict): A dictionary mapping (job_id, task_id) tuples to the corresponding start variables. + - "presences" (dict): A dictionary mapping (job_id, task_id, alt_id) tuples to the corresponding + presence variables. + + """ + num_jobs = data["num_jobs"] + num_machines = data["num_machines"] + jobs = data["jobs"] + all_jobs = range(num_jobs) + all_machines = range(num_machines) + + model = cp_model.CpModel() + + horizon = 0 + for job in jobs: + for task in job: + max_task_duration = 0 + for alternative in task: + max_task_duration = max(max_task_duration, alternative[0]) + horizon += max_task_duration + + print(f"Horizon = {horizon}") + + # Global storage of variables. + intervals_per_resources = collections.defaultdict(list) + starts = {} # indexed by (job_id, task_id). + presences = {} # indexed by (job_id, task_id, alt_id). + job_ends = [] + + # Scan the jobs and create the relevant variables and intervals. + for job_id in all_jobs: + job = jobs[job_id] + num_tasks = len(job) + previous_end = None + for task_id in range(num_tasks): + task = job[task_id] + + min_duration = task[0][0] + max_duration = task[0][0] + + num_alternatives = len(task) + all_alternatives = range(num_alternatives) + + for alt_id in range(1, num_alternatives): + alt_duration = task[alt_id][0] + min_duration = min(min_duration, alt_duration) + max_duration = max(max_duration, alt_duration) + + # Create main interval for the task. + suffix_name = "_j%i_t%i" % (job_id, task_id) + start = model.NewIntVar(0, horizon, "start" + suffix_name) + duration = model.NewIntVar( + min_duration, max_duration, "duration" + suffix_name + ) + end = model.NewIntVar(0, horizon, "end" + suffix_name) + interval = model.NewIntervalVar( + start, duration, end, "interval" + suffix_name + ) + + # Store the start for the solution. + starts[(job_id, task_id)] = start + + # Add precedence with previous task in the same job. + if previous_end is not None: + model.Add(start >= previous_end) + previous_end = end + + # Create alternative intervals. + if num_alternatives > 1: + l_presences = [] + for alt_id in all_alternatives: + alt_suffix = "_j%i_t%i_a%i" % (job_id, task_id, alt_id) + l_presence = model.NewBoolVar("presence" + alt_suffix) + l_start = model.NewIntVar(0, horizon, "start" + alt_suffix) + l_duration = task[alt_id][0] + l_end = model.NewIntVar(0, horizon, "end" + alt_suffix) + l_interval = model.NewOptionalIntervalVar( + l_start, l_duration, l_end, l_presence, "interval" + alt_suffix + ) + l_presences.append(l_presence) + + # Link the primary/global variables with the local ones. + model.Add(start == l_start).OnlyEnforceIf(l_presence) + model.Add(duration == l_duration).OnlyEnforceIf(l_presence) + model.Add(end == l_end).OnlyEnforceIf(l_presence) + + # Add the local interval to the right machine. + intervals_per_resources[task[alt_id][1]].append(l_interval) + + # Store the presences for the solution. + presences[(job_id, task_id, alt_id)] = l_presence + + # Select exactly one presence variable. + model.AddExactlyOne(l_presences) + else: + intervals_per_resources[task[0][1]].append(interval) + presences[(job_id, task_id, 0)] = model.NewConstant(1) + + job_ends.append(previous_end) + + # Create machines constraints. + for machine_id in all_machines: + intervals = intervals_per_resources[machine_id] + if len(intervals) > 1: + model.AddNoOverlap(intervals) + + # Makespan objective + makespan = model.NewIntVar(0, horizon, "makespan") + model.AddMaxEquality(makespan, job_ends) + model.Minimize(makespan) + return model, {"starts": starts, "presences": presences} + + +def solve_model( + model: cp_model.CpModel, time_limit: float | int +) -> tuple[cp_model.CpSolver, int, int]: + """ + Solves the given constraint programming model within the specified time limit. + + Args: + model: The constraint programming model to solve. + time_limit: The maximum time limit in seconds for solving the model. + + Returns: + A tuple containing the solver object, the status of the solver, and the number of solution_methods found. + """ + solver = cp_model.CpSolver() + solver.parameters.max_time_in_seconds = time_limit + solution_printer = SolutionPrinter() + status = solver.Solve(model, solution_printer) + solution_count = solution_printer.solution_count() + return solver, status, solution_count diff --git a/solutions/FJSP_DRL/__init__.py b/solutions/FJSP_DRL/__init__.py deleted file mode 100644 index 034a5ee..0000000 --- a/solutions/FJSP_DRL/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from gym.envs.registration import register - -# Registrar for the gym environment -# https://www.gymlibrary.ml/content/environment_creation/ for reference -register( - id='fjsp-v0', # Environment name (including version number) - entry_point='solutions.FJSP_DRL.env:FJSPEnv', # The location of the environment class, like 'foldername.filename:classname' -) \ No newline at end of file diff --git a/solutions/FJSP_DRL/train.py b/solutions/FJSP_DRL/train.py index 3a27d02..d1808d7 100644 --- a/solutions/FJSP_DRL/train.py +++ b/solutions/FJSP_DRL/train.py @@ -14,7 +14,7 @@ import logging from collections import deque -import gymnasium as gym +import gym import pandas as pd from pathlib import Path import torch