Source code for labscheduler.solvers.cp_solver
"""
A solver implementation using google OR-tools to model and solve the JSSP as a constraint program(CP)
"""
import contextlib
import importlib.metadata
import importlib.util
import pickle
import re
import subprocess
import time
from pathlib import Path
from threading import Lock, Thread
from labscheduler.logging_manager import scheduler_logger
from labscheduler.solver_interface import AlgorithmInfo, JSSPSolver
from labscheduler.structures import (
JSSP,
Schedule,
SolutionQuality,
)
if not importlib.util.find_spec("ortools"):
msg = (
"The required optional dependency 'ortools' is not installed. Please install it using "
"'pip install .[cpsolver]' to use the CP solver."
)
raise ModuleNotFoundError(msg)
# Check ortools version. If its too low, the solver will not work
ortools_version = importlib.metadata.version("ortools")
ortools_version_parts = []
for part in ortools_version.split(".")[:3]:
match = re.match(r"\d+", part)
ortools_version_parts.append(int(match.group()) if match else 0)
ortools_version_tuple = tuple(ortools_version_parts + [0] * (3 - len(ortools_version_parts)))
if ortools_version_tuple < (6, 0, 0):
msg = f"The CP solver requires ortools>=6.0.0, but ortools=={ortools_version} is installed."
raise ImportError(msg)
[docs]
class CPSolver(JSSPSolver):
def __init__(self):
# A long-lived worker subprocess pays the (~0.7s) ortools import only once and then serves
# every solve. It is respawned on demand if it dies (segfault) or hangs past the deadline.
self._proc: subprocess.Popen | None = None
self._lock = Lock() # only one solve at a time may use the pipe
self._worker_path = Path(__file__).parent / "cp_worker.py"
[docs]
def compute_schedule(
self,
inst: JSSP,
time_limit: float,
offset: float,
**kwargs,
) -> tuple[Schedule | None, SolutionQuality]:
problem_data = {"inst": inst, "time_limit": time_limit, "offset": offset, **kwargs}
with self._lock:
proc = self._ensure_worker()
worker_start = time.perf_counter()
result = {}
# Try to receive the pickled result (blocks until one result frame arrives or the pipe closes)
def reader():
with contextlib.suppress(Exception):
result.update(pickle.load(proc.stdout)) # noqa: S301
# Send pickled problem; a broken pipe means the worker just died -> respawn on next call
try:
pickle.dump(problem_data, proc.stdin)
proc.stdin.flush()
except (BrokenPipeError, OSError):
self._kill_worker()
return None, SolutionQuality.INFEASIBLE
# give the process generous (1 extra second) time to produce a result
t = Thread(target=reader, daemon=True)
t.start()
t.join(time_limit + 1)
scheduler_logger.info(
f"[timing] worker (model + solve + result i/o): {time.perf_counter() - worker_start:.3f}s",
)
if t.is_alive() or not result:
# hung past the deadline, or crashed mid-solve (EOF -> empty result):
# the worker is now in an unknown state, so discard it. Fallbacks cover the caller.
self._kill_worker()
return None, SolutionQuality.INFEASIBLE
return result.get("schedule"), result.get("quality")
[docs]
@staticmethod
def get_algorithm_info() -> AlgorithmInfo:
return AlgorithmInfo(
name="CP-Solver",
is_optimal=True,
success_guaranty=True,
max_problem_size=300,
)
[docs]
def is_solvable(self, inst: JSSP) -> bool:
# model, vars_ = self.create_model(inst, 0) # noqa: ERA001
# TODO: there is a method in OR-tools to check solvability
# state = self.solve_cp(model, vars_, 5) # noqa: ERA001
# return state in {OPTIMAL, FEASIBLE} # noqa: ERA001
return True
[docs]
def _ensure_worker(self) -> subprocess.Popen:
"""Return a live worker, (re)spawning it if it never started or has died."""
if self._proc is not None and self._proc.poll() is None:
return self._proc
t0 = time.perf_counter()
# stderr is inherited (not captured), so the worker's logs stream straight to our stderr.
self._proc = subprocess.Popen( # noqa: S603
["python", str(self._worker_path)], # noqa: S607
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
scheduler_logger.info(f"[timing] spawned CP worker in {time.perf_counter() - t0:.3f}s")
return self._proc
[docs]
def _kill_worker(self):
if self._proc is not None:
with contextlib.suppress(Exception):
self._proc.kill()
self._proc.wait()
self._proc = None