Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Job shop problem integration #303

Merged
merged 2 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions discrete_optimization/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
MSPSPLIB_REPO_URL = "https://github.com/youngkd/MSPSP-InstLib"
MSPSPLIB_REPO_URL_SHA1 = "f77644175b84beed3bd365315412abee1a15eea1"

JSPLIB_REPO_URL = "https://github.com/tamy0612/JSPLIB"
JSPLIB_REPO_URL_SHA1 = "eea2b60dd7e2f5c907ff7302662c61812eb7efdf"

MSLIB_DATASET_URL = "http://www.projectmanagement.ugent.be/sites/default/files/datasets/MSRCPSP/MSLIB.zip"
MSLIB_DATASET_RELATIVE_PATH = "MSLIB.zip"
Expand Down Expand Up @@ -342,6 +344,42 @@ def fetch_data_for_mis(data_home: Optional[str] = None):
urlcleanup()


def fetch_data_from_jsplib_repo(data_home: Optional[str] = None):
"""Fetch data from jsplib repo. (for jobshop problems)

https://github.com/tamy0612/JSPLIB

Params:
data_home: Specify the cache folder for the datasets. By default
all discrete-optimization data is stored in '~/discrete_optimization_data' subfolders.

"""
#  get the proper data directory
data_home = get_data_home(data_home=data_home)

# download in a temporary file the repo data
url = f"{JSPLIB_REPO_URL}/archive/{JSPLIB_REPO_URL_SHA1}.zip"
try:
local_file_path, headers = urlretrieve(url)
# extract only data
with zipfile.ZipFile(local_file_path) as zipf:
namelist = zipf.namelist()
rootdir = namelist[0].split("/")[0]
dataset_dir = f"{data_home}/jobshop"
os.makedirs(dataset_dir, exist_ok=True)
dataset_prefix_in_zip = f"{rootdir}/instances/"
for name in namelist:
if name.startswith(dataset_prefix_in_zip):
zipf.extract(name, path=dataset_dir)
for datafile in glob.glob(f"{dataset_dir}/{dataset_prefix_in_zip}/*"):
os.replace(
src=datafile, dst=f"{dataset_dir}/{os.path.basename(datafile)}"
)
os.removedirs(f"{dataset_dir}/{dataset_prefix_in_zip}")
finally:
urlcleanup()


def fetch_all_datasets(data_home: Optional[str] = None):
"""Fetch data used by examples for all packages.

Expand All @@ -355,6 +393,7 @@ def fetch_all_datasets(data_home: Optional[str] = None):
fetch_data_from_imopse(data_home=data_home)
fetch_data_from_solutionsupdate(data_home=data_home)
fetch_data_for_mis(data_home=data_home)
fetch_data_from_jsplib_repo(data_home=data_home)


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions discrete_optimization/jsp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright (c) 2024 AIRBUS and its affiliates.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
# Module containing the implementation job shop like problem, and variations.
56 changes: 56 additions & 0 deletions discrete_optimization/jsp/job_shop_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright (c) 2024 AIRBUS and its affiliates.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import os
from typing import Optional

from discrete_optimization.datasets import get_data_home
from discrete_optimization.jsp.job_shop_problem import JobShopProblem, Subjob


def get_data_available(
data_folder: Optional[str] = None, data_home: Optional[str] = None
) -> list[str]:
"""Get datasets available for jobshop.

Params:
data_folder: folder where datasets for jobshop whould be find.
If None, we look in "jobshop" subdirectory of `data_home`.
data_home: root directory for all datasets. Is None, set by
default to "~/discrete_optimization_data "

"""
if data_folder is None:
data_home = get_data_home(data_home=data_home)
data_folder = f"{data_home}/jobshop"

try:
files = [f for f in os.listdir(data_folder)]
except FileNotFoundError:
files = []
return [os.path.abspath(os.path.join(data_folder, f)) for f in files]


def parse_file(file_path: str):
with open(file_path, "r") as file:
lines = file.readlines()
processed_line = 0
problem = []
for line in lines:
if not (line.startswith("#")):
split_line = line.split()
job = []
if processed_line == 0:
nb_jobs = int(split_line[0])
nb_machines = int(split_line[1])
else:
for num, n in enumerate(split_line):
if num % 2 == 0:
machine = int(n)
else:
job.append(
{"machine_id": machine, "processing_time": int(n)}
)
problem.append(job)
processed_line += 1
return JobShopProblem(list_jobs=[[Subjob(**x) for x in y] for y in problem])
91 changes: 91 additions & 0 deletions discrete_optimization/jsp/job_shop_problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright (c) 2024 AIRBUS and its affiliates.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
# Job shop model, this was initially implemented in a course material
# here https://github.com/erachelson/seq_dec_mak/blob/main/scheduling_newcourse/correction/nb2_jobshopsolver.py


from discrete_optimization.generic_tools.do_problem import *


class SolutionJobshop(Solution):
def __init__(
self, problem: "JobShopProblem", schedule: list[list[tuple[int, int]]]
):
# For each job and sub-job, start and end time given as tuple of int.
self.problem = problem
self.schedule = schedule

def copy(self) -> "Solution":
return SolutionJobshop(problem=self.problem, schedule=self.schedule)

def change_problem(self, new_problem: "Problem") -> None:
self.problem = new_problem


class Subjob:
machine_id: int
processing_time: int

def __init__(self, machine_id: int, processing_time: int):
"""Define data of a given subjob"""
self.machine_id = machine_id
self.processing_time = processing_time


class JobShopProblem(Problem):
n_jobs: int
n_machines: int
list_jobs: list[list[Subjob]]

def __init__(
self, list_jobs: list[list[Subjob]], n_jobs: int = None, n_machines: int = None
):
self.n_jobs = n_jobs
self.n_machines = n_machines
self.list_jobs = list_jobs
if self.n_jobs is None:
self.n_jobs = len(list_jobs)
if self.n_machines is None:
self.n_machines = len(
set([y.machine_id for x in self.list_jobs for y in x])
)
self.n_all_jobs = sum(len(subjob) for subjob in self.list_jobs)
# Store for each machine the list of sub-job given as (index_job, index_sub-job)
self.job_per_machines = {i: [] for i in range(self.n_machines)}
for k in range(self.n_jobs):
for sub_k in range(len(list_jobs[k])):
self.job_per_machines[list_jobs[k][sub_k].machine_id] += [(k, sub_k)]

def evaluate(self, variable: SolutionJobshop) -> dict[str, float]:
return {"makespan": max(x[-1][1] for x in variable.schedule)}

def satisfy(self, variable: SolutionJobshop) -> bool:
for m in self.job_per_machines:
sorted_ = sorted(
[variable.schedule[x[0]][x[1]] for x in self.job_per_machines[m]],
key=lambda y: y[0],
)
for i in range(1, len(sorted_)):
if sorted_[i][0] < sorted_[i - 1][1]:
return False
for job in range(self.n_jobs):
for s_j in range(1, len(variable.schedule[job])):
if variable.schedule[job][s_j][0] < variable.schedule[job][s_j - 1][1]:
return False
return True

def get_attribute_register(self) -> EncodingRegister:
return EncodingRegister(dict_attribute_to_type={})

def get_solution_type(self) -> type[Solution]:
return SolutionJobshop

def get_objective_register(self) -> ObjectiveRegister:
return ObjectiveRegister(
dict_objective_to_doc={
"makespan": ObjectiveDoc(type=TypeObjective.OBJECTIVE, default_weight=1)
},
objective_sense=ModeOptim.MINIMIZATION,
objective_handling=ObjectiveHandling.AGGREGATE,
)
41 changes: 41 additions & 0 deletions discrete_optimization/jsp/job_shop_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright (c) 2024 AIRBUS and its affiliates.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from discrete_optimization.jsp.job_shop_problem import JobShopProblem, Subjob
from discrete_optimization.rcpsp.rcpsp_model import RCPSPModel


def transform_jsp_to_rcpsp(jsp_problem: JobShopProblem) -> RCPSPModel:
mode_details = {}
successors = {}
tasks_list = ["source"]
successors["source"] = [(i, 0) for i in range(jsp_problem.n_jobs)]
successors["sink"] = []
mode_details["source"] = {1: {"duration": 0}}
mode_details["sink"] = {1: {"duration": 0}}
for i in range(jsp_problem.n_jobs):
for j in range(len(jsp_problem.list_jobs[i])):
tasks_list.append((i, j))
mode_details[(i, j)] = {
1: {
"duration": jsp_problem.list_jobs[i][j].processing_time,
f"machine_{jsp_problem.list_jobs[i][j].machine_id}": 1,
}
}
if j < len(jsp_problem.list_jobs[i]) - 1:
successors[(i, j)] = [(i, j + 1)]
successors[(i, len(jsp_problem.list_jobs[i]) - 1)] = ["sink"]
tasks_list.append("sink")

rcpsp_problem = RCPSPModel(
resources={f"machine_{i}": 1 for i in range(jsp_problem.n_machines)},
non_renewable_resources=[],
successors=successors,
mode_details=mode_details,
tasks_list=tasks_list,
source_task="source",
sink_task="sink",
horizon=5000,
)
return rcpsp_problem
Empty file.
115 changes: 115 additions & 0 deletions discrete_optimization/jsp/solvers/cpsat_jsp_solver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Copyright (c) 2024 AIRBUS and its affiliates.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
# Adaptation of
# https://github.com/erachelson/seq_dec_mak/blob/main/scheduling_newcourse/correction/nb2_jobshopsolver.py
import logging
from typing import Any

from ortools.sat.python.cp_model import CpModel, CpSolverSolutionCallback, Domain

from discrete_optimization.generic_tools.do_solver import WarmstartMixin
from discrete_optimization.generic_tools.ortools_cpsat_tools import OrtoolsCPSatSolver
from discrete_optimization.jsp.job_shop_problem import JobShopProblem, SolutionJobshop

logger = logging.getLogger(__name__)


class CPSatJspSolver(OrtoolsCPSatSolver, WarmstartMixin):
problem: JobShopProblem

def __init__(self, problem: JobShopProblem, **kwargs: Any):
super().__init__(problem, **kwargs)
self.variables = {}

def init_model(self, **args: Any) -> None:
self.cp_model = CpModel()
# dummy value, todo : compute a better bound
max_time = args.get(
"max_time",
sum(
sum(subjob.processing_time for subjob in job)
for job in self.problem.list_jobs
),
)
# Write variables, constraints
starts = [
[
self.cp_model.NewIntVar(0, max_time, f"starts_{j, k}")
for k in range(len(self.problem.list_jobs[j]))
]
for j in range(self.problem.n_jobs)
]
# Same idea for ends
ends = [
[
self.cp_model.NewIntVar(0, max_time, f"ends_{j, k}")
for k in range(len(self.problem.list_jobs[j]))
]
for j in range(self.problem.n_jobs)
]
# Create the interval variables
intervals = [
[
self.cp_model.NewIntervalVar(
start=starts[j][k],
size=self.problem.list_jobs[j][k].processing_time,
end=ends[j][k],
name=f"task_{j, k}",
)
for k in range(len(self.problem.list_jobs[j]))
]
for j in range(self.problem.n_jobs)
]
# Precedence constraint between sub-parts of each job.
for j in range(self.problem.n_jobs):
for k in range(1, len(self.problem.list_jobs[j])):
self.cp_model.Add(starts[j][k] >= ends[j][k - 1])
# No overlap task on the same machine.
for machine in self.problem.job_per_machines:
self.cp_model.AddNoOverlap(
[intervals[x[0]][x[1]] for x in self.problem.job_per_machines[machine]]
)
# Objective value variable
makespan = self.cp_model.NewIntVar(0, max_time, name="makespan")
self.cp_model.AddMaxEquality(makespan, [ends[i][-1] for i in range(len(ends))])
self.cp_model.Minimize(makespan)
# Store the variables in some dictionaries.
self.variables["starts"] = starts
self.variables["ends"] = ends
self.variables["intervals"] = intervals

def set_warm_start(self, solution: SolutionJobshop) -> None:
for job_index in range(len(solution.schedule)):
for subjob_index in range(len(solution.schedule[job_index])):
self.cp_model.AddHint(
self.variables["starts"][job_index][subjob_index],
solution.schedule[job_index][subjob_index][0],
)
self.cp_model.AddHint(
self.variables["ends"][job_index][subjob_index],
solution.schedule[job_index][subjob_index][1],
)

def retrieve_solution(
self, cpsolvercb: CpSolverSolutionCallback
) -> SolutionJobshop:
logger.info(
f"Objective ={cpsolvercb.ObjectiveValue()}, bound = {cpsolvercb.BestObjectiveBound()}"
)
schedule = []
for job_index in range(len(self.variables["starts"])):
sched_job = []
for subjob_index in range(len(self.variables["starts"][job_index])):
sched_job.append(
(
cpsolvercb.Value(
self.variables["starts"][job_index][subjob_index]
),
cpsolvercb.Value(
self.variables["ends"][job_index][subjob_index]
),
)
)
schedule.append(sched_job)
return SolutionJobshop(problem=self.problem, schedule=schedule)
Loading