diff --git a/README.md b/README.md index 955d0c0..1342546 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ JobShopLib is a Python package for creating, solving, and visualizing Job Shop S It follows a modular design, allowing users to easily extend the library with new solvers, dispatching rules, visualization functions, etc. -See the [documentation](https://job-shop-lib.readthedocs.io/en/latest/) for more details about the latest version (1.0.0a2). +See the [documentation](https://job-shop-lib.readthedocs.io/en/latest/) for more details about the latest version. ## Installation :package: @@ -36,7 +36,7 @@ See [this](https://colab.research.google.com/drive/1XV_Rvq1F2ns6DFG8uNj66q_rcoww Version 1.0.0 is currently in alpha stage and can be installed with: ```bash -pip install job-shop-lib==1.0.0a3 +pip install job-shop-lib==1.0.0a4 ``` Although this version is not stable and may contain breaking changes in subsequent releases, it is recommended to install it to access the new reinforcement learning environments and familiarize yourself with new changes (see the [latest pull requests](https://github.com/Pabloo22/job_shop_lib/pulls?q=is%3Apr+is%3Aclosed)). This version is the first one with a [documentation page](https://job-shop-lib.readthedocs.io/en/latest/). diff --git a/docs/source/index.rst b/docs/source/index.rst index 1b2950a..175af38 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,7 +6,7 @@ Welcome to JobShopLib's documentation! ====================================== -**Version:** 1.0.0-a.3 +**Version:** 1.0.0-a.4 JobShopLib is a Python package for creating, solving, and visualizing diff --git a/job_shop_lib/_job_shop_instance.py b/job_shop_lib/_job_shop_instance.py index ab64500..1e0cfdf 100644 --- a/job_shop_lib/_job_shop_instance.py +++ b/job_shop_lib/_job_shop_instance.py @@ -15,16 +15,47 @@ class JobShopInstance: """Data structure to store a Job Shop Scheduling Problem instance. - Additional attributes such as `num_jobs` or `num_machines` can be computed - from the instance and are cached for performance if they require expensive - computations. + Additional attributes such as ``num_machines`` or ``durations_matrix`` can + be computed from the instance and are cached for performance if they + require expensive computations. + + Methods: + + .. autosummary:: + :nosignatures: + + from_taillard_file + to_dict + from_matrices + set_operation_attributes + + Properties: + + .. autosummary:: + :nosignatures: + + num_jobs + num_machines + num_operations + is_flexible + durations_matrix + machines_matrix + durations_matrix_array + machines_matrix_array + operations_by_machine + max_duration + max_duration_per_job + max_duration_per_machine + job_durations + machine_loads + total_duration Attributes: jobs (list[list[Operation]]): A list of lists of operations. Each list of operations represents a job, and the operations are ordered by their position in the job. - The `job_id`, `position_in_job`, and `operation_id` attributes of - the operations are set when the instance is created. + The ``job_id``, ``position_in_job``, and `operation_id` attributes + of the operations are set when the instance is created. name (str): A string with the name of the instance. metadata (dict[str, Any]): @@ -34,11 +65,16 @@ class JobShopInstance: jobs: A list of lists of operations. Each list of operations represents a job, and the operations are ordered by their - position in the job. The `job_id`, `position_in_job`, and - `operation_id` attributes of the operations are set when the + position in the job. The ``job_id``, ``position_in_job``, and + ``operation_id`` attributes of the operations are set when the instance is created. name: A string with the name of the instance. + set_operation_attributes: + If True, the ``job_id``, ``position_in_job``, and ``operation_id`` + attributes of the operations are set when the instance is created. + See :meth:`set_operation_attributes` for more information. Defaults + to True. **metadata: Additional information about the instance. """ @@ -47,15 +83,37 @@ def __init__( self, jobs: list[list[Operation]], name: str = "JobShopInstance", + set_operation_attributes: bool = True, **metadata: Any, ): self.jobs: list[list[Operation]] = jobs - self.set_operation_attributes() + if set_operation_attributes: + self.set_operation_attributes() self.name: str = name self.metadata: dict[str, Any] = metadata def set_operation_attributes(self): - """Sets the job_id and position of each operation.""" + """Sets the ``job_id``, ``position_in_job``, and ``operation_id`` + attributes for each operation in the instance. + + The ``job_id`` attribute is set to the id of the job to which the + operation belongs. + + The ``position_in_job`` attribute is set to the + position of the operation in the job (starts from 0). + + The ``operation_id`` attribute is set to a unique identifier for the + operation (starting from 0). + + The formula to compute the ``operation_id`` in a job shop instance with + a fixed number of operations per job is: + + .. code-block:: python + + operation_id = job_id * num_operations_per_job + position_in_job + + """ + operation_id = 0 for job_id, job in enumerate(self.jobs): for position, operation in enumerate(job): @@ -90,8 +148,8 @@ def from_taillard_file( Additional information about the instance. Returns: - A JobShopInstance object with the operations read from the file, - and the name and metadata provided. + A :class:`JobShopInstance` object with the operations read from the + file, and the name and metadata provided. """ with open(file_path, "r", encoding=encoding) as file: lines = file.readlines() @@ -128,13 +186,17 @@ def to_dict(self) -> dict[str, Any]: like Taillard's. Returns: - The returned dictionary has the following structure: - { - "name": self.name, - "duration_matrix": self.durations_matrix, - "machines_matrix": self.machines_matrix, - "metadata": self.metadata, - } + dict[str, Any]: The returned dictionary has the following + structure: + + .. code-block:: python + + { + "name": self.name, + "duration_matrix": self.durations_matrix, + "machines_matrix": self.machines_matrix, + "metadata": self.metadata, + } """ return { "name": self.name, @@ -151,7 +213,8 @@ def from_matrices( name: str = "JobShopInstance", metadata: dict[str, Any] | None = None, ) -> JobShopInstance: - """Creates a JobShopInstance from duration and machines matrices. + """Creates a :class:`JobShopInstance` from duration and machines + matrices. Args: duration_matrix: @@ -168,7 +231,7 @@ def from_matrices( A dictionary with additional information about the instance. Returns: - A JobShopInstance object. + A :class:`JobShopInstance` object. """ jobs: list[list[Operation]] = [[] for _ in range(len(duration_matrix))] @@ -220,7 +283,7 @@ def num_operations(self) -> int: @functools.cached_property def is_flexible(self) -> bool: - """Returns True if any operation has more than one machine.""" + """Returns ``True`` if any operation has more than one machine.""" return any( any(len(operation.machines) > 1 for operation in job) for job in self.jobs @@ -230,12 +293,14 @@ def is_flexible(self) -> bool: def durations_matrix(self) -> list[list[int]]: """Returns the duration matrix of the instance. - The duration of the operation with `job_id` i and `position_in_job` j - is stored in the i-th position of the j-th list of the returned matrix: + The duration of the operation with ``job_id`` i and ``position_in_job`` + j is stored in the i-th position of the j-th list of the returned + matrix: + + .. code-block:: python + + duration = instance.durations_matrix[i][j] - ```python - duration = instance.durations_matrix[i][j] - ``` """ return [[operation.duration for operation in job] for job in self.jobs] @@ -252,9 +317,9 @@ def machines_matrix(self) -> list[list[list[int]]] | list[list[int]]: To access the machines of the operation with position i in the job with id j, the following code must be used: - ```python - machines = instance.machines_matrix[j][i] - ``` + .. code-block:: python + + machines = instance.machines_matrix[j][i] """ if self.is_flexible: @@ -269,8 +334,9 @@ def machines_matrix(self) -> list[list[list[int]]] | list[list[int]]: def durations_matrix_array(self) -> NDArray[np.float32]: """Returns the duration matrix of the instance as a numpy array. - The returned array has shape (num_jobs, max_num_operations_per_job). - Non-existing operations are filled with np.nan. + The returned array has shape (``num_jobs``, + ``max_num_operations_per_job``). + Non-existing operations are filled with ``np.nan``. Example: >>> jobs = [[Operation(0, 2), Operation(1, 3)], [Operation(0, 4)]] @@ -286,9 +352,9 @@ def durations_matrix_array(self) -> NDArray[np.float32]: def machines_matrix_array(self) -> NDArray[np.float32]: """Returns the machines matrix of the instance as a numpy array. - The returned array has shape (num_jobs, max_num_operations_per_job, - max_num_machines_per_operation). Non-existing machines are filled with - np.nan. + The returned array has shape (``num_jobs``, + ``max_num_operations_per_job``, ``max_num_machines_per_operation``). + Non-existing machines are filled with ``np.nan``. Example: >>> jobs = [ @@ -411,7 +477,7 @@ def total_duration(self) -> int: def _fill_matrix_with_nans_2d( matrix: list[list[int]], ) -> NDArray[np.float32]: - """Fills a matrix with np.nan values. + """Fills a matrix with ``np.nan`` values. Args: matrix: @@ -419,7 +485,7 @@ def _fill_matrix_with_nans_2d( Returns: A numpy array with the same shape as the input matrix, filled with - np.nan values. + ``np.nan`` values. """ max_length = max(len(row) for row in matrix) squared_matrix = np.full( @@ -433,7 +499,7 @@ def _fill_matrix_with_nans_2d( def _fill_matrix_with_nans_3d( matrix: list[list[list[int]]], ) -> NDArray[np.float32]: - """Fills a 3D matrix with np.nan values. + """Fills a 3D matrix with ``np.nan`` values. Args: matrix: @@ -441,7 +507,7 @@ def _fill_matrix_with_nans_3d( Returns: A numpy array with the same shape as the input matrix, filled with - np.nan values. + ``np.nan`` values. """ max_length = max(len(row) for row in matrix) max_inner_length = len(matrix[0][0]) diff --git a/job_shop_lib/_operation.py b/job_shop_lib/_operation.py index 4cb1c19..825c001 100644 --- a/job_shop_lib/_operation.py +++ b/job_shop_lib/_operation.py @@ -42,11 +42,20 @@ class Operation: "The time it takes to perform the operation. Often referred" " to as the processing time." ), - "job_id": "The id of the job the operation belongs to.", - "position_in_job": "The index of the operation in the job.", + "job_id": ( + "The id of the job the operation belongs to. Defaults to -1. " + "It is usually set by the :class:`JobShopInstance` class after " + "initialization." + ), + "position_in_job": ( + "The index of the operation in the job. Defaults to -1. " + "It is usually set by the :class:`JobShopInstance` class after " + "initialization." + ), "operation_id": ( "The id of the operation. This is unique within a " - ":class:`JobShopInstance`." + ":class:`JobShopInstance`. Defaults to -1. It is usually set by " + "the :class:`JobShopInstance` class after initialization." ), } diff --git a/job_shop_lib/_schedule.py b/job_shop_lib/_schedule.py index b787270..be813a5 100644 --- a/job_shop_lib/_schedule.py +++ b/job_shop_lib/_schedule.py @@ -25,6 +25,16 @@ class Schedule: is_complete add reset + + Args: + instance: + The :class:`JobShopInstance` object that the schedule is for. + schedule: + A list of lists of :class:`ScheduledOperation` objects. Each + list represents the order of operations on a machine. If + not provided, the schedule is initialized as an empty schedule. + **metadata: + Additional information about the schedule. """ __slots__ = { @@ -48,18 +58,6 @@ def __init__( schedule: list[list[ScheduledOperation]] | None = None, **metadata: Any, ): - """Initializes the object with the given instance and schedule. - - Args: - instance: - The :class:`JobShopInstance` object that the schedule is for. - schedule: - A list of lists of :class:`ScheduledOperation` objects. Each - list represents the order of operations on a machine. If - not provided, the schedule is initialized as an empty schedule. - **metadata: - Additional information about the schedule. - """ if schedule is None: schedule = [[] for _ in range(instance.num_machines)] diff --git a/job_shop_lib/_scheduled_operation.py b/job_shop_lib/_scheduled_operation.py index 84764f1..1d465ef 100644 --- a/job_shop_lib/_scheduled_operation.py +++ b/job_shop_lib/_scheduled_operation.py @@ -5,7 +5,21 @@ class ScheduledOperation: - """Data structure to store a scheduled operation.""" + """Data structure to store a scheduled operation. + + Args: + operation: + The :class:`Operation` object that is scheduled. + start_time: + The time at which the operation is scheduled to start. + machine_id: + The id of the machine on which the operation is scheduled. + + Raises: + ValidationError: + If the given machine_id is not in the list of valid machines + for the operation. + """ __slots__ = { "operation": "The :class:`Operation` object that is scheduled.", @@ -16,21 +30,6 @@ class ScheduledOperation: } def __init__(self, operation: Operation, start_time: int, machine_id: int): - """Initializes a new instance of the :class:`ScheduledOperation` class. - - Args: - operation: - The :class:`Operation` object that is scheduled. - start_time: - The time at which the operation is scheduled to start. - machine_id: - The id of the machine on which the operation is scheduled. - - Raises: - ValidationError: - If the given machine_id is not in the list of valid machines - for the operation. - """ self.operation: Operation = operation self.start_time: int = start_time self._machine_id = machine_id diff --git a/job_shop_lib/dispatching/_dispatcher.py b/job_shop_lib/dispatching/_dispatcher.py index fc0bfbc..5f54e9b 100644 --- a/job_shop_lib/dispatching/_dispatcher.py +++ b/job_shop_lib/dispatching/_dispatcher.py @@ -29,6 +29,18 @@ class DispatcherObserver(abc.ABC): dispatcher: The :class:`Dispatcher` instance to observe. + Args: + dispatcher: + The :class:`Dispatcher` instance to observe. + subscribe: + If ``True``, automatically subscribes the observer to the + dispatcher when it is initialized. Defaults to ``True``. + + Raises: + ValidationError: If ``is_singleton`` is ``True`` and an observer of the + same type already exists in the dispatcher's list of + subscribers. + Example: .. code-block:: python @@ -61,21 +73,6 @@ def __init__( *, subscribe: bool = True, ): - """Initializes the observer with the :class:`Dispatcher` and subscribes - to it. - - Args: - dispatcher: - The `Dispatcher` instance to observe. - subscribe: - If True, automatically subscribes the observer to the - dispatcher. - - Raises: - ValidationError: If ``is_singleton`` is True and an observer of the - same type already exists in the dispatcher's list of - subscribers. - """ if self._is_singleton and any( isinstance(observer, self.__class__) for observer in dispatcher.subscribers diff --git a/job_shop_lib/dispatching/_dispatcher_observer_config.py b/job_shop_lib/dispatching/_dispatcher_observer_config.py index 865fa1e..8ed02d0 100644 --- a/job_shop_lib/dispatching/_dispatcher_observer_config.py +++ b/job_shop_lib/dispatching/_dispatcher_observer_config.py @@ -26,14 +26,21 @@ class DispatcherObserverConfig(Generic[T]): keyword arguments to pass to the dispatcher observer constructor while not containing the ``dispatcher`` argument. - Attributes: + Args: class_type: Type of the class to be initialized. It can be the class type, an enum value, or a string. This is useful for the creation of - DispatcherObserver instances from the factory functions. + :class:`~job_shop_lib.dispatching.DispatcherObserver` instances + from the factory functions. kwargs: Keyword arguments needed to initialize the class. It must not contain the ``dispatcher`` argument. + + .. seealso:: + + - :class:`~job_shop_lib.dispatching.DispatcherObserver` + - :func:`job_shop_lib.dispatching.feature_observers.\\ + feature_observer_factory` """ # We use the type hint T, instead of ObserverType, to allow for string or @@ -44,7 +51,13 @@ class DispatcherObserverConfig(Generic[T]): # This allows for the creation of a FeatureObserver instance # from the factory function. class_type: T + """Type of the class to be initialized. It can be the class type, an + enum value, or a string. This is useful for the creation of + :class:`DispatcherObserver` instances from the factory functions.""" + kwargs: dict[str, Any] = field(default_factory=dict) + """Keyword arguments needed to initialize the class. It must not + contain the ``dispatcher`` argument.""" def __post_init__(self): if "dispatcher" in self.kwargs: diff --git a/job_shop_lib/dispatching/_factories.py b/job_shop_lib/dispatching/_factories.py index ca85edb..056e00e 100644 --- a/job_shop_lib/dispatching/_factories.py +++ b/job_shop_lib/dispatching/_factories.py @@ -51,13 +51,13 @@ def create_composite_operation_filter( 'non_immediate_machines' or any Callable that takes a :class:`~job_shop_lib.dispatching.Dispatcher` instance and a list of :class:`~job_shop_lib.Operation` instances as input - and returns a list of :class:`~job_shop_lib.Operation`instances. + and returns a list of :class:`~job_shop_lib.Operation` instances. Returns: A function that takes a :class:`~job_shop_lib.dispatching.Dispatcher` instance and a list of :class:`~job_shop_lib.Operation` instances as input and returns a list of - :class:`~job_shop_lib.Operation`instances based on + :class:`~job_shop_lib.Operation` instances based on the specified list of filter strategies. Raises: diff --git a/job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py b/job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py index 0ca265c..482a084 100644 --- a/job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py +++ b/job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py @@ -193,7 +193,6 @@ def __str__(self): dispatcher=dispatcher_, ) for observer_type in feature_observer_types_ - if not observer_type == FeatureObserverType.COMPOSITE # and not FeatureObserverType.EARLIEST_START_TIME ] composite_observer_ = CompositeFeatureObserver( diff --git a/job_shop_lib/dispatching/feature_observers/_factory.py b/job_shop_lib/dispatching/feature_observers/_factory.py index 888d391..8cffbf3 100644 --- a/job_shop_lib/dispatching/feature_observers/_factory.py +++ b/job_shop_lib/dispatching/feature_observers/_factory.py @@ -1,4 +1,4 @@ -"""Contains factory functions for creating node feature encoders.""" +"""Contains factory functions for creating :class:`FeatureObserver`s.""" from enum import Enum @@ -18,8 +18,12 @@ class FeatureObserverType(str, Enum): """Enumeration of the different feature observers. - Each feature observer is associated with a string value that can be used - to create the feature observer using the factory function. + Each :class:`FeatureObserver` is associated with a string value that can be + used to create the :class:`FeatureObserver` using the factory function. + + It does not include the :class:`CompositeFeatureObserver` class since this + observer is often managed separately from the others. For example, a + common use case is to create a list of feature observers and pass them to """ IS_READY = "is_ready" @@ -29,7 +33,6 @@ class FeatureObserverType(str, Enum): POSITION_IN_JOB = "position_in_job" REMAINING_OPERATIONS = "remaining_operations" IS_COMPLETED = "is_completed" - COMPOSITE = "composite" # FeatureObserverConfig = DispatcherObserverConfig[ @@ -43,7 +46,7 @@ class FeatureObserverType(str, Enum): def feature_observer_factory( - feature_creator_type: ( + feature_observer_type: ( str | FeatureObserverType | type[FeatureObserver] @@ -51,29 +54,29 @@ def feature_observer_factory( ), **kwargs, ) -> FeatureObserver: - """Creates and returns a node feature creator based on the specified - node feature creator type. + """Creates and returns a :class:`FeatureObserver` based on the specified + :class:`FeatureObserver` type. Args: feature_creator_type: - The type of node feature creator to create. + The type of :class:`FeatureObserver` to create. **kwargs: - Additional keyword arguments to pass to the node - feature creator constructor. + Additional keyword arguments to pass to the + :class:`FeatureObserver` constructor. Returns: - A node feature creator instance. + A :class:`FeatureObserver` instance. """ - if isinstance(feature_creator_type, DispatcherObserverConfig): + if isinstance(feature_observer_type, DispatcherObserverConfig): return feature_observer_factory( - feature_creator_type.class_type, - **feature_creator_type.kwargs, + feature_observer_type.class_type, + **feature_observer_type.kwargs, **kwargs, ) # if the instance is of type type[FeatureObserver] we can just # call the object constructor with the keyword arguments - if isinstance(feature_creator_type, type): - return feature_creator_type(**kwargs) + if isinstance(feature_observer_type, type): + return feature_observer_type(**kwargs) mapping: dict[FeatureObserverType, type[FeatureObserver]] = { FeatureObserverType.IS_READY: IsReadyObserver, @@ -84,5 +87,5 @@ def feature_observer_factory( FeatureObserverType.REMAINING_OPERATIONS: RemainingOperationsObserver, FeatureObserverType.IS_COMPLETED: IsCompletedObserver, } - feature_creator = mapping[feature_creator_type] # type: ignore[index] - return feature_creator(**kwargs) + feature_observer = mapping[feature_observer_type] # type: ignore[index] + return feature_observer(**kwargs) diff --git a/job_shop_lib/dispatching/feature_observers/_is_completed_observer.py b/job_shop_lib/dispatching/feature_observers/_is_completed_observer.py index b29f6f5..41cbf8d 100644 --- a/job_shop_lib/dispatching/feature_observers/_is_completed_observer.py +++ b/job_shop_lib/dispatching/feature_observers/_is_completed_observer.py @@ -51,6 +51,7 @@ class IsCompletedObserver(FeatureObserver): def __init__( self, dispatcher: Dispatcher, + *, feature_types: list[FeatureType] | FeatureType | None = None, subscribe: bool = True, ): diff --git a/job_shop_lib/generation/_general_instance_generator.py b/job_shop_lib/generation/_general_instance_generator.py index 7734eb6..5ab0ce9 100644 --- a/job_shop_lib/generation/_general_instance_generator.py +++ b/job_shop_lib/generation/_general_instance_generator.py @@ -17,36 +17,58 @@ class GeneralInstanceGenerator(InstanceGenerator): durations, and more. The class supports both single instance generation and iteration over - multiple instances, controlled by the `iteration_limit` parameter. It - implements the iterator protocol, allowing it to be used in a `for` loop. + multiple instances, controlled by the ``iteration_limit`` parameter. It + implements the iterator protocol, allowing it to be used in a ``for`` loop. Note: When used as an iterator, the generator will produce instances until it - reaches the specified `iteration_limit`. If `iteration_limit` is None, - it will continue indefinitely. + reaches the specified ``iteration_limit``. If ``iteration_limit`` is + ``None``, it will continue indefinitely. Attributes: num_jobs_range: The range of the number of jobs to generate. If a single - int is provided, it is used as both the minimum and maximum. + ``int`` is provided, it is used as both the minimum and maximum. duration_range: The range of durations for each operation. num_machines_range: The range of the number of machines available. If a - single int is provided, it is used as both the minimum and maximum. + single ``int`` is provided, it is used as both the minimum and + maximum. machines_per_operation: Specifies how many machines each operation - can be assigned to. If a single int is provided, it is used for + can be assigned to. If a single ``int`` is provided, it is used for all operations. allow_less_jobs_than_machines: - If True, allows generating instances where the number of jobs is - less than the number of machines. + If ``True``, allows generating instances where the number of jobs + is less than the number of machines. allow_recirculation: - If True, a job can visit the same machine more than once. + If ``True``, a job can visit the same machine more than once. name_suffix: A suffix to append to each instance's name for identification. seed: Seed for the random number generator to ensure reproducibility. + + Args: + num_jobs: + The range of the number of jobs to generate. + num_machines: + The range of the number of machines available. + duration_range: + The range of durations for each operation. + allow_less_jobs_than_machines: + Allows instances with fewer jobs than machines. + allow_recirculation: + Allows jobs to visit the same machine multiple times. + machines_per_operation: + Specifies how many machines each operation can be assigned to. + If a single ``int`` is provided, it is used for all operations. + name_suffix: + Suffix for instance names. + seed: + Seed for the random number generator. + iteration_limit: + Maximum number of instances to generate in iteration mode. """ def __init__( # pylint: disable=too-many-arguments @@ -61,29 +83,6 @@ def __init__( # pylint: disable=too-many-arguments seed: int | None = None, iteration_limit: int | None = None, ): - """Initializes the instance generator with the given parameters. - - Args: - num_jobs: - The range of the number of jobs to generate. - num_machines: - The range of the number of machines available. - duration_range: - The range of durations for each operation. - allow_less_jobs_than_machines: - Allows instances with fewer jobs than machines. - allow_recirculation: - Allows jobs to visit the same machine multiple times. - machines_per_operation: - Specifies how many machines each operation can be assigned to. - If a single int is provided, it is used for all operations. - name_suffix: - Suffix for instance names. - seed: - Seed for the random number generator. - iteration_limit: - Maximum number of instances to generate in iteration mode. - """ super().__init__( num_jobs=num_jobs, num_machines=num_machines, @@ -153,7 +152,7 @@ def create_random_operation( Args: available_machines: A list of available machine_ids to choose from. - If None, all machines are available. + If ``None``, all machines are available. """ duration = random.randint(*self.duration_range) diff --git a/job_shop_lib/generation/_instance_generator.py b/job_shop_lib/generation/_instance_generator.py index 81469d0..e4b0554 100644 --- a/job_shop_lib/generation/_instance_generator.py +++ b/job_shop_lib/generation/_instance_generator.py @@ -32,6 +32,20 @@ class InstanceGenerator(abc.ABC): A suffix to append to each instance's name for identification. seed: Seed for the random number generator to ensure reproducibility. + + Args: + num_jobs: + The range of the number of jobs to generate. + num_machines: + The range of the number of machines available. + duration_range: + The range of durations for each operation. + name_suffix: + Suffix for instance names. + seed: + Seed for the random number generator. + iteration_limit: + Maximum number of instances to generate in iteration mode. """ def __init__( # pylint: disable=too-many-arguments @@ -43,23 +57,6 @@ def __init__( # pylint: disable=too-many-arguments seed: int | None = None, iteration_limit: int | None = None, ): - """Initializes the instance generator with the given parameters. - - Args: - num_jobs: - The range of the number of jobs to generate. - num_machines: - The range of the number of machines available. - duration_range: - The range of durations for each operation. - name_suffix: - Suffix for instance names. - seed: - Seed for the random number generator. - iteration_limit: - Maximum number of instances to generate in iteration mode. - """ - if isinstance(num_jobs, int): num_jobs = (num_jobs, num_jobs) if isinstance(num_machines, int): diff --git a/job_shop_lib/generation/_transformations.py b/job_shop_lib/generation/_transformations.py index b1cb66e..21e96c4 100644 --- a/job_shop_lib/generation/_transformations.py +++ b/job_shop_lib/generation/_transformations.py @@ -111,7 +111,17 @@ def apply(self, instance: JobShopInstance) -> JobShopInstance: class RemoveJobs(Transformation): """Removes jobs randomly until the number of jobs is within a specified - range.""" + range. + + Args: + min_jobs: + The minimum number of jobs to remain in the instance. + max_jobs: + The maximum number of jobs to remain in the instance. + target_jobs: + If specified, the number of jobs to remain in the + instance. Overrides ``min_jobs`` and ``max_jobs``. + """ def __init__( self, @@ -120,13 +130,6 @@ def __init__( target_jobs: int | None = None, suffix: str | None = None, ): - """ - Args: - min_jobs: The minimum number of jobs to remain in the instance. - max_jobs: The maximum number of jobs to remain in the instance. - target_jobs: If specified, the number of jobs to remain in the - instance. Overrides min_jobs and max_jobs. - """ if suffix is None: suffix = f"_jobs={min_jobs}-{max_jobs}" super().__init__(suffix=suffix) diff --git a/job_shop_lib/graphs/__init__.py b/job_shop_lib/graphs/__init__.py index e6fdb6c..03e4e21 100644 --- a/job_shop_lib/graphs/__init__.py +++ b/job_shop_lib/graphs/__init__.py @@ -7,6 +7,7 @@ Node NodeType build_disjunctive_graph + build_solved_disjunctive_graph build_agent_task_graph build_complete_agent_task_graph build_agent_task_graph_with_jobs @@ -18,6 +19,7 @@ from job_shop_lib.graphs._job_shop_graph import JobShopGraph, NODE_ATTR from job_shop_lib.graphs._build_disjunctive_graph import ( build_disjunctive_graph, + build_solved_disjunctive_graph, add_disjunctive_edges, add_conjunctive_edges, add_source_sink_nodes, @@ -62,4 +64,5 @@ "add_global_node", "add_machine_global_edges", "add_job_global_edges", + "build_solved_disjunctive_graph", ] diff --git a/job_shop_lib/graphs/_build_disjunctive_graph.py b/job_shop_lib/graphs/_build_disjunctive_graph.py index 3bc079c..3235324 100644 --- a/job_shop_lib/graphs/_build_disjunctive_graph.py +++ b/job_shop_lib/graphs/_build_disjunctive_graph.py @@ -18,14 +18,15 @@ import itertools -from job_shop_lib import JobShopInstance +from job_shop_lib import JobShopInstance, Schedule from job_shop_lib.graphs import JobShopGraph, EdgeType, NodeType, Node def build_disjunctive_graph(instance: JobShopInstance) -> JobShopGraph: """Builds and returns a disjunctive graph for the given job shop instance. - This function creates a complete disjunctive graph from a JobShopInstance. + This function creates a complete disjunctive graph from a + :JobShopInstance. It starts by initializing a JobShopGraph object and proceeds by adding disjunctive edges between operations using the same machine, conjunctive edges between successive operations in the same job, and finally, special @@ -40,7 +41,7 @@ def build_disjunctive_graph(instance: JobShopInstance) -> JobShopGraph: the graph. Returns: - JobShopGraph: A JobShopGraph object representing the disjunctive graph + A :class:`JobShopGraph` object representing the disjunctive graph of the job shop scheduling problem. """ graph = JobShopGraph(instance) @@ -51,6 +52,43 @@ def build_disjunctive_graph(instance: JobShopInstance) -> JobShopGraph: return graph +def build_solved_disjunctive_graph(schedule: Schedule) -> JobShopGraph: + """Builds and returns a disjunctive graph for the given solved schedule. + + This function constructs a disjunctive graph from the given schedule, + keeping only the disjunctive edges that represent the chosen ordering + of operations on each machine as per the solution schedule. + + Args: + schedule (Schedule): The solved schedule that contains the sequencing + of operations on each machine. + + Returns: + A JobShopGraph object representing the disjunctive graph + of the solved job shop scheduling problem. + """ + # Build the base disjunctive graph from the job shop instance + graph = JobShopGraph(schedule.instance) + add_conjunctive_edges(graph) + add_source_sink_nodes(graph) + add_source_sink_edges(graph) + + # Iterate over each machine and add only the edges that match the solution + # order + for machine_schedule in schedule.schedule: + for i, scheduled_operation in enumerate(machine_schedule): + if i + 1 >= len(machine_schedule): + break + next_scheduled_operation = machine_schedule[i + 1] + graph.add_edge( + scheduled_operation.operation.operation_id, + next_scheduled_operation.operation.operation_id, + type=EdgeType.DISJUNCTIVE, + ) + + return graph + + def add_disjunctive_edges(graph: JobShopGraph) -> None: """Adds disjunctive edges to the graph.""" diff --git a/job_shop_lib/graphs/graph_updaters/_graph_updater.py b/job_shop_lib/graphs/graph_updaters/_graph_updater.py index 74dd14c..dd44e36 100644 --- a/job_shop_lib/graphs/graph_updaters/_graph_updater.py +++ b/job_shop_lib/graphs/graph_updaters/_graph_updater.py @@ -23,6 +23,17 @@ class GraphUpdater(DispatcherObserver): job_shop_graph: The current job shop graph. This is the graph that is updated after each scheduled operation. + + Args: + dispatcher: + The dispatcher instance to observe. + job_shop_graph: + The job shop graph to update. + subscribe: + Whether to subscribe to the dispatcher. If ``True``, the + observer will subscribe to the dispatcher when it is + initialized. If ``False``, the observer will not subscribe + to the dispatcher. """ def __init__( @@ -32,19 +43,6 @@ def __init__( *, subscribe: bool = True, ): - """Initializes the class. - - Args: - dispatcher: - The dispatcher instance to observe. - job_shop_graph: - The job shop graph to update. - subscribe: - Whether to subscribe to the dispatcher. If ``True``, the - observer will subscribe to the dispatcher when it is - initialized. If ``False``, the observer will not subscribe - to the dispatcher. - """ super().__init__(dispatcher, subscribe=subscribe) self.initial_job_shop_graph = deepcopy(job_shop_graph) self.job_shop_graph = job_shop_graph diff --git a/job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py b/job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py index e06f257..dbf2280 100644 --- a/job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py +++ b/job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py @@ -25,9 +25,24 @@ class ResidualGraphUpdater(GraphUpdater): Attributes: remove_completed_machine_nodes: - If True, removes completed machine nodes from the graph. + If ``True``, removes completed machine nodes from the graph. remove_completed_job_nodes: - If True, removes completed job nodes from the graph. + If ``True``, removes completed job nodes from the graph. + + Args: + dispatcher: + The dispatcher instance to observe. + job_shop_graph: + The job shop graph to update. + subscribe: + If ``True``, automatically subscribes the observer to the + dispatcher. Defaults to ``True``. + remove_completed_machine_nodes: + If ``True``, removes completed machine nodes from the graph. + Defaults to ``True``. + remove_completed_job_nodes: + If ``True``, removes completed job nodes from the graph. + Defaults to ``True``. """ def __init__( @@ -39,24 +54,6 @@ def __init__( remove_completed_machine_nodes: bool = True, remove_completed_job_nodes: bool = True, ): - """Initializes the residual graph updater. - - Args: - dispatcher: - The dispatcher instance to observe. - job_shop_graph: - The job shop graph to update. - subscribe: - If True, automatically subscribes the observer to the - dispatcher. Defaults to True. - remove_completed_machine_nodes: - If True, removes completed machine nodes from the graph. - Defaults to True. - remove_completed_job_nodes: - If True, removes completed job nodes from the graph. - Defaults to True. - """ - self._is_completed_observer: None | IsCompletedObserver = None self.remove_completed_job_nodes = remove_completed_job_nodes self.remove_completed_machine_nodes = remove_completed_machine_nodes diff --git a/job_shop_lib/reinforcement_learning/__init__.py b/job_shop_lib/reinforcement_learning/__init__.py index c80edd9..cc0c0e5 100644 --- a/job_shop_lib/reinforcement_learning/__init__.py +++ b/job_shop_lib/reinforcement_learning/__init__.py @@ -1,12 +1,24 @@ -"""Package for reinforcement learning components.""" +"""Contains reinforcement learning components. + + +.. autosummary:: + + SingleJobShopGraphEnv + MultiJobShopGraphEnv + ObservationDict + ObservationSpaceKey + RewardObserver + MakespanReward + IdleTimeReward + RenderConfig + add_padding + +""" from job_shop_lib.reinforcement_learning._types_and_constants import ( ObservationSpaceKey, RenderConfig, ObservationDict, - GanttChartWrapperConfig, - GifConfig, - VideoConfig, ) from job_shop_lib.reinforcement_learning._reward_observers import ( @@ -30,9 +42,6 @@ "RewardObserver", "MakespanReward", "IdleTimeReward", - "GanttChartWrapperConfig", - "GifConfig", - "VideoConfig", "SingleJobShopGraphEnv", "RenderConfig", "ObservationDict", diff --git a/job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py b/job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py index 54d19f4..b1f908b 100644 --- a/job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py +++ b/job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py @@ -42,11 +42,11 @@ class MultiJobShopGraphEnv(gym.Env): The observation space includes: - - removed_nodes: Binary vector indicating removed nodes. - - edge_index: Edge list in COO format. - - operations: Matrix of operation features. - - jobs: Matrix of job features (if applicable). - - machines: Matrix of machine features (if applicable). + - removed_nodes: Binary vector indicating removed nodes. + - edge_index: Edge list in COO format. + - operations: Matrix of operation features. + - jobs: Matrix of job features (if applicable). + - machines: Matrix of machine features (if applicable). Internally, the class creates a :class:`~job_shop_lib.reinforcement_learning.SingleJobShopGraphEnv` @@ -57,29 +57,37 @@ class MultiJobShopGraphEnv(gym.Env): instance_generator: A :class:`~job_shop_lib.generation.InstanceGenerator` that generates a new problem instance on each reset. + action_space: :class:`gymnasium.spaces.Discrete`) action space with size equal to the maximum number of jobs. + observation_space: Dictionary of observation spaces. Keys are defined in :class:`~job_shop_lib.reinforcement_learning.ObservationSpaceKey`. + single_job_shop_graph_env: Environment for a specific Job Shop Scheduling Problem instance. See :class:`SingleJobShopGraphEnv`. + graph_initializer: Function to create the initial graph representation. It should take a :class:`~job_shop_lib.JobShopInstance` as input and return a :class:`~job_shop_lib.graphs.JobShopGraph`. + render_mode: Rendering mode for visualization. Supported modes are: + - human: Renders the current Gannt chart. - save_video: Saves a video of the Gantt chart. Used only if the schedule is completed. - save_gif: Saves a GIF of the Gantt chart. Used only if the schedule is completed. + render_config: Configuration for rendering. See :class:`~job_shop_lib.RenderConfig`. + feature_observer_configs: List of :class:`~job_shop_lib.dispatching.DispatcherObserverConfig` for feature observers. @@ -87,11 +95,63 @@ class MultiJobShopGraphEnv(gym.Env): Configuration for the reward function. See :class:`~job_shop_lib.dispatching.DispatcherObserverConfig` and :class:`~job_shop_lib.dispatching.RewardObserver`. + graph_updater_config: Configuration for the graph updater. The graph updater is used to update the graph representation after each action. See :class:`~job_shop_lib.dispatching.DispatcherObserverConfig` and :class:`~job_shop_lib.graphs.GraphUpdater`. + Args: + instance_generator: + A :class:`~job_shop_lib.generation.InstanceGenerator` that + generates a new problem instance on each reset. + + feature_observer_configs: + Configurations for feature observers. Each configuration + should be a + :class:`~job_shop_lib.dispatching.DispatcherObserverConfig` + with a class type that inherits from + :class:`~job_shop_lib.dispatching.FeatureObserver` or a string + or enum that represents a built-in feature observer. + + graph_initializer: + Function to create the initial graph representation. + If ``None``, the default graph initializer is used: + :func:`~job_shop_lib.graphs.build_agent_task_graph`. + graph_updater_config: + Configuration for the graph updater. The graph updater is used + to update the graph representation after each action. If + ``None``, the default graph updater is used: + :class:`~job_shop_lib.graphs.ResidualGraphUpdater`. + + ready_operations_filter: + Function to filter ready operations. If ``None``, the default + filter is used: + :func:`~job_shop_lib.dispatching.filter_dominated_operations`. + + reward_function_config: + Configuration for the reward function. If ``None``, the default + reward function is used: + :class:`~job_shop_lib.dispatching.MakespanReward`. + + render_mode: + Rendering mode for visualization. Supported modes are: + + - human: Renders the current Gannt chart. + - save_video: Saves a video of the Gantt chart. Used only if + the schedule is completed. + - save_gif: Saves a GIF of the Gantt chart. Used only if the + schedule is completed. + render_config: + Configuration for rendering. See + :class:`~job_shop_lib.RenderConfig`. + + use_padding: + Whether to use padding in observations. If True, all matrices + are padded to fixed sizes based on the maximum instance size. + Values are padded with -1, except for the "removed_nodes" key, + which is padded with ``True``, indicating that the node is + removed. """ def __init__( @@ -114,53 +174,6 @@ def __init__( render_config: RenderConfig | None = None, use_padding: bool = True, ) -> None: - """Initializes the environment. - - Args: - instance_generator: - A :class:`~job_shop_lib.generation.InstanceGenerator` that - generates a new problem instance on each reset. - feature_observer_configs: - Configurations for feature observers. Each configuration - should be a - :class:`~job_shop_lib.dispatching.DispatcherObserverConfig` - with a class type that inherits from - :class:`~job_shop_lib.dispatching.FeatureObserver` or a string - or enum that represents a built-in feature observer. - graph_initializer: - Function to create the initial graph representation. - If ``None``, the default graph initializer is used: - :func:`~job_shop_lib.graphs.build_agent_task_graph`. - graph_updater_config: - Configuration for the graph updater. The graph updater is used - to update the graph representation after each action. If - ``None``, the default graph updater is used: - :class:`~job_shop_lib.graphs.ResidualGraphUpdater`. - ready_operations_filter: - Function to filter ready operations. If ``None``, the default - filter is used: - :func:`~job_shop_lib.dispatching.filter_dominated_operations`. - reward_function_config: - Configuration for the reward function. If ``None``, the default - reward function is used: - :class:`~job_shop_lib.dispatching.MakespanReward`. - render_mode: - Rendering mode for visualization. Supported modes are: - - human: Renders the current Gannt chart. - - save_video: Saves a video of the Gantt chart. Used only if - the schedule is completed. - - save_gif: Saves a GIF of the Gantt chart. Used only if the - schedule is completed. - render_config: - Configuration for rendering. See - :class:`~job_shop_lib.RenderConfig`. - use_padding: - Whether to use padding in observations. If True, all matrices - are padded to fixed sizes based on the maximum instance size. - Values are padded with -1, except for the "removed_nodes" key, - which is padded with ``True``, indicating that the node is - removed. - """ super().__init__() # Create an instance with the maximum size @@ -303,16 +316,15 @@ def step( Returns: A tuple containing the following elements: + - The observation of the environment. - The reward obtained. - Whether the environment is done. - Whether the episode was truncated (always False). - A dictionary with additional information. The dictionary - contains the following keys: - - "feature_names": The names of the features in the - observation. - - "available_operations": The operations that are ready to be - scheduled. + contains the following keys: ``"feature_names"``, The names of + the features in the observation; ``"available_operations"``, the + operations that are ready to be scheduled. """ obs, reward, done, truncated, info = ( self.single_job_shop_graph_env.step(action) diff --git a/job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py b/job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py index 8de12f2..2528fd7 100644 --- a/job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py +++ b/job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py @@ -45,6 +45,7 @@ class SingleJobShopGraphEnv(gym.Env): Observation Space: A dictionary with the following keys: + - "removed_nodes": Binary vector indicating removed graph nodes. - "edge_list": Matrix of graph edges in COO format. - Feature matrices: Keys corresponding to the composite observer @@ -54,43 +55,76 @@ class SingleJobShopGraphEnv(gym.Env): MultiDiscrete space representing (job_id, machine_id) pairs. Render Modes: - - "human": Displays the current Gantt chart. - - "save_video": Saves a video of the complete Gantt chart. - - "save_gif": Saves a GIF of the complete Gantt chart. + + - "human": Displays the current Gantt chart. + - "save_video": Saves a video of the complete Gantt chart. + - "save_gif": Saves a GIF of the complete Gantt chart. Attributes: dispatcher: Manages the scheduling process. See :class:`~job_shop_lib.dispatching.Dispatcher`. + composite_observer: A :class:`~job_shop_lib.dispatching.feature_observers. CompositeFeatureObserver` which aggregates features from multiple observers. + graph_updater: Updates the graph representation after each action. See :class:`~job_shop_lib.graphs.GraphUpdater`. + reward_function: Computes rewards for actions taken. See :class:`~job_shop_lib.reinforcement_learning.RewardObserver`. + action_space: Defines the action space. The action is a tuple of two integers (job_id, machine_id). The machine_id can be -1 if the selected operation can only be scheduled in one machine. + observation_space: Defines the observation space. The observation is a dictionary with the following keys: + - "removed_nodes": Binary vector indicating removed graph nodes. - "edge_list": Matrix of graph edges in COO format. - Feature matrices: Keys corresponding to the composite observer features (e.g., "operations", "jobs", "machines"). + render_mode: The mode for rendering the environment ("human", "save_video", "save_gif"). + gantt_chart_creator: Creates Gantt chart visualizations. See :class:`~job_shop_lib.visualization.GanttChartCreator`. + use_padding: Whether to use padding in observations. Padding maintains the + observation space shape when the number of nodes changes. + + Args: + job_shop_graph: + The JobShopGraph instance representing the job shop problem. + feature_observer_configs: + A list of FeatureObserverConfig instances for the feature + observers. + reward_function_config: + The configuration for the reward function. + graph_updater_config: + The configuration for the graph updater. + ready_operations_filter: + The function to use for pruning dominated operations. + render_mode: + The mode for rendering the environment ("human", "save_video", + "save_gif"). + render_config: + Configuration for rendering (e.g., paths for saving videos + or GIFs). See :class:`~job_shop_lib.visualization.RenderConfig`. + use_padding: + Whether to use padding in observations. Padding maintains the + observation space shape when the number of nodes changes. """ metadata = {"render_modes": ["human", "save_video", "save_gif"]} @@ -116,29 +150,6 @@ def __init__( render_config: RenderConfig | None = None, use_padding: bool = True, ) -> None: - """Initializes the SingleJobShopGraphEnv environment. - - Args: - job_shop_graph: - The JobShopGraph instance representing the job shop problem. - feature_observer_configs: - A list of FeatureObserverConfig instances for the feature - observers. - reward_function_config: - The configuration for the reward function. - graph_updater_config: - The configuration for the graph updater. - ready_operations_filter: - The function to use for pruning dominated operations. - render_mode: - The mode for rendering the environment ("human", "save_video", - "save_gif"). - render_config: - Configuration for rendering (e.g., paths for saving videos - or GIFs). - use_padding: - Whether to use padding for the edge index. - """ super().__init__() # Used for resetting the environment self.initial_job_shop_graph = deepcopy(job_shop_graph) @@ -236,16 +247,16 @@ def step( Returns: A tuple containing the following elements: + - The observation of the environment. - The reward obtained. - Whether the environment is done. - Whether the episode was truncated (always False). - A dictionary with additional information. The dictionary - contains the following keys: - - "feature_names": The names of the features in the - observation. - - "available_operations": The operations that are ready to be - scheduled. + contains the following keys: "feature_names", the names of the + features in the observation; "available_operations", the + operations that are ready to be scheduled. + """ job_id, machine_id = action operation = self.dispatcher.next_operation(job_id) diff --git a/job_shop_lib/reinforcement_learning/_types_and_constants.py b/job_shop_lib/reinforcement_learning/_types_and_constants.py index 0c1d973..8cafc06 100644 --- a/job_shop_lib/reinforcement_learning/_types_and_constants.py +++ b/job_shop_lib/reinforcement_learning/_types_and_constants.py @@ -8,7 +8,7 @@ from job_shop_lib.dispatching.feature_observers import FeatureType from job_shop_lib.visualization import ( - GanttChartWrapperConfig, + PartialGanttChartPlotterConfig, GifConfig, VideoConfig, ) @@ -17,7 +17,7 @@ class RenderConfig(TypedDict, total=False): """Configuration needed to initialize the `GanttChartCreator` class.""" - gantt_chart_wrapper_config: GanttChartWrapperConfig + partial_gantt_chart_plotter_config: PartialGanttChartPlotterConfig video_config: VideoConfig gif_config: GifConfig diff --git a/job_shop_lib/visualization/__init__.py b/job_shop_lib/visualization/__init__.py index 6502900..66cc779 100644 --- a/job_shop_lib/visualization/__init__.py +++ b/job_shop_lib/visualization/__init__.py @@ -1,4 +1,19 @@ -"""Package for visualization.""" +"""Contains functions and classes for visualizing job shop scheduling problems. + +.. autosummary:: + + plot_gantt_chart + get_partial_gantt_chart_plotter + PartialGanttChartPlotter + create_gantt_chart_video + create_gantt_chart_gif + plot_disjunctive_graph + plot_agent_task_graph + GanttChartCreator + GifConfig + VideoConfig + +""" from job_shop_lib.visualization._plot_gantt_chart import plot_gantt_chart from job_shop_lib.visualization._gantt_chart_video_and_gif_creation import ( @@ -8,17 +23,19 @@ get_partial_gantt_chart_plotter, create_video_from_frames, create_gif_from_frames, + PartialGanttChartPlotter, ) -from job_shop_lib.visualization._disjunctive_graph import ( +from job_shop_lib.visualization._plot_disjunctive_graph import ( plot_disjunctive_graph, + duration_labeler, ) -from job_shop_lib.visualization._agent_task_graph import ( +from job_shop_lib.visualization._plot_agent_task_graph import ( plot_agent_task_graph, three_columns_layout, ) from job_shop_lib.visualization._gantt_chart_creator import ( GanttChartCreator, - GanttChartWrapperConfig, + PartialGanttChartPlotterConfig, GifConfig, VideoConfig, ) @@ -35,7 +52,9 @@ "plot_agent_task_graph", "three_columns_layout", "GanttChartCreator", - "GanttChartWrapperConfig", + "PartialGanttChartPlotterConfig", "GifConfig", "VideoConfig", + "PartialGanttChartPlotter", + "duration_labeler", ] diff --git a/job_shop_lib/visualization/_disjunctive_graph.py b/job_shop_lib/visualization/_disjunctive_graph.py deleted file mode 100644 index fd52dba..0000000 --- a/job_shop_lib/visualization/_disjunctive_graph.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Module for visualizing the disjunctive graph of a job shop instance.""" - -import functools -from typing import Optional, Callable -import warnings -import copy - -import matplotlib -import matplotlib.pyplot as plt -import networkx as nx -from networkx.drawing.nx_agraph import graphviz_layout - -from job_shop_lib import JobShopInstance -from job_shop_lib.graphs import ( - JobShopGraph, - EdgeType, - NodeType, - Node, - build_disjunctive_graph, -) - - -Layout = Callable[[nx.Graph], dict[str, tuple[float, float]]] - - -# This function could be improved by a function extraction refactoring -# (see `plot_gantt_chart` -# function as a reference in how to do it). That would solve the -# "too many locals" warning. However, this refactoring is not a priority at -# the moment. To compensate, sections are separated by comments. -# For the "too many arguments" warning no satisfactory solution was -# found. I believe is still better than using `**kwargs` and losing the -# function signature or adding a dataclass for configuration (it would add -# unnecessary complexity). -# pylint: disable=too-many-arguments, too-many-locals -def plot_disjunctive_graph( - job_shop: JobShopGraph | JobShopInstance, - figsize: tuple[float, float] = (6, 4), - node_size: int = 1600, - title: Optional[str] = None, - layout: Optional[Layout] = None, - edge_width: int = 2, - font_size: int = 10, - arrow_size: int = 35, - alpha=0.95, - node_font_color: str = "white", - color_map: str = "Dark2_r", - draw_disjunctive_edges: bool = True, -) -> plt.Figure: - """Returns a plot of the disjunctive graph of the instance.""" - - if isinstance(job_shop, JobShopInstance): - job_shop_graph = build_disjunctive_graph(job_shop) - else: - job_shop_graph = job_shop - - # Set up the plot - # ---------------- - plt.figure(figsize=figsize) - if title is None: - title = ( - f"Disjunctive Graph Visualization: {job_shop_graph.instance.name}" - ) - plt.title(title) - - # Set up the layout - # ----------------- - if layout is None: - layout = functools.partial( - graphviz_layout, prog="dot", args="-Grankdir=LR" - ) - - temp_graph = copy.deepcopy(job_shop_graph.graph) - # Remove disjunctive edges to get a better layout - temp_graph.remove_edges_from( - [ - (u, v) - for u, v, d in job_shop_graph.graph.edges(data=True) - if d["type"] == EdgeType.DISJUNCTIVE - ] - ) - - try: - pos = layout(temp_graph) - except ImportError: - warnings.warn( - "Default layout requires pygraphviz http://pygraphviz.github.io/. " - "Using spring layout instead.", - ) - pos = nx.spring_layout(temp_graph) - - # Draw nodes - # ---------- - node_colors = [ - _get_node_color(node) - for node in job_shop_graph.nodes - if not job_shop_graph.is_removed(node.node_id) - ] - - nx.draw_networkx_nodes( - job_shop_graph.graph, - pos, - node_size=node_size, - node_color=node_colors, - alpha=alpha, - cmap=matplotlib.colormaps.get_cmap(color_map), - ) - - # Draw edges - # ---------- - conjunctive_edges = [ - (u, v) - for u, v, d in job_shop_graph.graph.edges(data=True) - if d["type"] == EdgeType.CONJUNCTIVE - ] - disjunctive_edges = [ - (u, v) - for u, v, d in job_shop_graph.graph.edges(data=True) - if d["type"] == EdgeType.DISJUNCTIVE - ] - - nx.draw_networkx_edges( - job_shop_graph.graph, - pos, - edgelist=conjunctive_edges, - width=edge_width, - edge_color="black", - arrowsize=arrow_size, - ) - - if draw_disjunctive_edges: - nx.draw_networkx_edges( - job_shop_graph.graph, - pos, - edgelist=disjunctive_edges, - width=edge_width, - edge_color="red", - arrowsize=arrow_size, - ) - - # Draw node labels - # ---------------- - operation_nodes = job_shop_graph.nodes_by_type[NodeType.OPERATION] - - labels = {} - source_node = job_shop_graph.nodes_by_type[NodeType.SOURCE][0] - labels[source_node] = "S" - - sink_node = job_shop_graph.nodes_by_type[NodeType.SINK][0] - labels[sink_node] = "T" - for operation_node in operation_nodes: - if job_shop_graph.is_removed(operation_node.node_id): - continue - labels[operation_node] = ( - f"m={operation_node.operation.machine_id}\n" - f"d={operation_node.operation.duration}" - ) - - nx.draw_networkx_labels( - job_shop_graph.graph, - pos, - labels=labels, - font_color=node_font_color, - font_size=font_size, - font_family="sans-serif", - ) - - # Final touches - # ------------- - plt.axis("off") - plt.tight_layout() - # Create a legend to indicate the meaning of the edge colors - conjunctive_patch = matplotlib.patches.Patch( - color="black", label="conjunctive edges" - ) - disjunctive_patch = matplotlib.patches.Patch( - color="red", label="disjunctive edges" - ) - - # Add to the legend the meaning of m and d - text = "m = machine_id\nd = duration" - extra = matplotlib.patches.Rectangle( - (0, 0), - 1, - 1, - fc="w", - fill=False, - edgecolor="none", - linewidth=0, - label=text, - ) - plt.legend( - handles=[conjunctive_patch, disjunctive_patch, extra], - loc="upper left", - bbox_to_anchor=(1.05, 1), - borderaxespad=0.0, - ) - return plt.gcf() - - -def _get_node_color(node: Node) -> int: - """Returns the color of the node.""" - if node.node_type == NodeType.SOURCE: - return -1 - if node.node_type == NodeType.SINK: - return -1 - if node.node_type == NodeType.OPERATION: - return node.operation.machine_id - - raise ValueError("Invalid node type.") diff --git a/job_shop_lib/visualization/_gantt_chart_creator.py b/job_shop_lib/visualization/_gantt_chart_creator.py index 8eb8b7c..dbb719f 100644 --- a/job_shop_lib/visualization/_gantt_chart_creator.py +++ b/job_shop_lib/visualization/_gantt_chart_creator.py @@ -1,7 +1,6 @@ """Home of the `GanttChartCreator` class and its configuration types.""" from typing import TypedDict - import matplotlib.pyplot as plt from job_shop_lib.dispatching import ( @@ -15,57 +14,87 @@ ) -class GanttChartWrapperConfig(TypedDict, total=False): - """Configuration for creating the plot function with the - `plot_gantt_chart_wrapper` function.""" +class PartialGanttChartPlotterConfig(TypedDict, total=False): + """A dictionary with the configuration for creating the + :class:`PartialGanttChartPlotter` function. + + .. seealso:: + + - :class:`PartialGanttChartPlotter` + - :func:`get_partial_gantt_chart_plotter` + """ title: str | None + """The title of the Gantt chart.""" + cmap: str + """The colormap to use in the Gantt chart.""" + show_available_operations: bool + """Whether to show available operations in each step.""" -# We can't use Required here because it's not available in Python 3.10 -class _GifConfigRequired(TypedDict): - """Required configuration for creating the GIF.""" +class GifConfig(TypedDict, total=False): + """A dictionary with the configuration for creating the GIF using the + :func:`create_gantt_chart_gif` function. - gif_path: str | None + .. seealso:: + :func:`create_gantt_chart_gif` + """ -class _GifConfigOptional(TypedDict, total=False): - """Optional configuration for creating the GIF.""" + gif_path: str | None + """The path to save the GIF. It must end with '.gif'.""" fps: int + """The frames per second of the GIF. Defaults to 1.""" + remove_frames: bool - frames_dir: str | None - plot_current_time: bool + """Whether to remove the frames after creating the GIF.""" + frames_dir: str | None + """The directory to store the frames.""" -class GifConfig(_GifConfigRequired, _GifConfigOptional): - """Configuration for creating the GIF using the `create_gannt_chart_video` - function.""" + plot_current_time: bool + """Whether to plot the current time in the Gantt chart.""" class VideoConfig(TypedDict, total=False): """Configuration for creating the video using the - `create_gannt_chart_video` function.""" + :func:`create_gantt_chart_video` function. + + .. seealso:: + + :func:`create_gantt_chart_video` + """ video_path: str | None + """The path to save the video. It must end with a valid video extension + (e.g., '.mp4').""" + fps: int + """The frames per second of the video. Defaults to 1.""" + remove_frames: bool + """Whether to remove the frames after creating the video.""" + frames_dir: str | None + """The directory to store the frames.""" + plot_current_time: bool + """Whether to plot the current time in the Gantt chart.""" class GanttChartCreator: """Facade class that centralizes the creation of Gantt charts, videos and GIFs. - It leverages a `HistoryObserver` to keep track of the operations being - scheduled and provides methods to plot the current state - of the schedule as a Gantt chart and to create a GIF that shows the - evolution of the schedule over time. + It leverages a :class:`HistoryObserver` to keep track of the operations + being scheduled and provides methods to plot the current state + of the schedule as a Gantt chart and to create a GIF or video that shows + the evolution of the schedule over time. - It adds a new `HistoryObserver` to the dispatcher if it does + It adds a new :class:`HistoryObserver` to the dispatcher if it does not have one already. Otherwise, it uses the observer already present. Attributes: @@ -81,72 +110,80 @@ class GanttChartCreator: Configuration for creating the video. plot_function: The function used to plot the Gantt chart when creating the GIF - or video. Created using the `plot_gantt_chart_wrapper` function. + or video. Created using the :func:`get_partial_gantt_chart_plotter` + function. + + Args: + dispatcher: + The :class:`Dispatcher` class that will be tracked using a + :class:`HistoryObserver`. + partial_gantt_chart_plotter_config: + Configuration for the Gantt chart wrapper function through the + :class:`PartialGanttChartPlotterConfig` class. Defaults to + ``None``. Valid keys are: + + - title: The title of the Gantt chart. + - cmap: The name of the colormap to use. + - show_available_operations: Whether to show available + operations in each step. + + If ``title`` or ``cmap`` are not provided here and + ``infer_gantt_chart_config`` is set to ``True``, the values from + ``gantt_chart_config`` will be used if they are present. + + .. seealso:: + + - :class:`PartialGanttChartPlotterConfig` + - :func:`get_partial_gantt_chart_plotter` + - :class:`PartialGanttChartPlotter` + + gif_config: + Configuration for creating the GIF. Defaults to ``None``. + Valid keys are: + + - gif_path: The path to save the GIF. + - fps: The frames per second of the GIF. + - remove_frames: Whether to remove the frames after creating + the GIF. + - frames_dir: The directory to store the frames. + - plot_current_time: Whether to plot the current time in the + Gantt chart. + video_config: + Configuration for creating the video. Defaults to ``None``. + Valid keys are: + + - video_path: The path to save the video. + - fps: The frames per second of the video. + - remove_frames: Whether to remove the frames after creating + the video. + - frames_dir: The directory to store the frames. + - plot_current_time: Whether to plot the current time in the + Gantt chart. """ def __init__( self, dispatcher: Dispatcher, - gantt_chart_wrapper_config: GanttChartWrapperConfig | None = None, + partial_gantt_chart_plotter_config: ( + PartialGanttChartPlotterConfig | None + ) = None, gif_config: GifConfig | None = None, video_config: VideoConfig | None = None, ): - """Initializes the GanttChartCreator with the given configurations - and history observer. - - This class adds a new `HistoryObserver` to the dispatcher if it does - not have one already. Otherwise, it uses the observer already present. - - Args: - dispatcher: - The `Dispatcher` class that will be tracked using a - `HistoryObserver`. - gantt_chart_wrapper_config: - Configuration for the Gantt chart wrapper function. Valid keys - are: - - title: The title of the Gantt chart. - - cmap: The name of the colormap to use. - - show_available_operations: Whether to show available - operations in each step. - - If `title` or `cmap` are not provided here and - `infer_gantt_chart_config` is set to True, the values from - `gantt_chart_config` will be used if they are present. - gif_config: - Configuration for creating the GIF. Defaults to None. - Valid keys are: - - gif_path: The path to save the GIF. - - fps: The frames per second of the GIF. - - remove_frames: Whether to remove the frames after creating - the GIF. - - frames_dir: The directory to store the frames. - - plot_current_time: Whether to plot the current time in the - Gantt chart. - video_config: - Configuration for creating the video. Defaults to None. - Valid keys are: - - video_path: The path to save the video. - - fps: The frames per second of the video. - - remove_frames: Whether to remove the frames after creating - the video. - - frames_dir: The directory to store the frames. - - plot_current_time: Whether to plot the current time in the - Gantt chart. - """ if gif_config is None: - gif_config = {"gif_path": None} - if gantt_chart_wrapper_config is None: - gantt_chart_wrapper_config = {} + gif_config = {} + if partial_gantt_chart_plotter_config is None: + partial_gantt_chart_plotter_config = {} if video_config is None: video_config = {} self.gif_config = gif_config - self.gannt_chart_wrapper_config = gantt_chart_wrapper_config + self.gannt_chart_wrapper_config = partial_gantt_chart_plotter_config self.video_config = video_config self.history_observer: HistoryObserver = ( dispatcher.create_or_get_observer(HistoryObserver) ) - self.plot_function = get_partial_gantt_chart_plotter( + self.partial_gantt_chart_plotter = get_partial_gantt_chart_plotter( **self.gannt_chart_wrapper_config ) @@ -169,37 +206,38 @@ def plot_gantt_chart(self) -> plt.Figure: """Plots the current Gantt chart of the schedule. Returns: - tuple[plt.Figure, plt.Axes]: - The figure and axes of the plotted Gantt chart. + The figure of the plotted Gantt chart. """ - return self.plot_function( + a = self.partial_gantt_chart_plotter( self.schedule, None, self.dispatcher.available_operations(), self.dispatcher.current_time(), ) + return a def create_gif(self) -> None: """Creates a GIF of the schedule being built using the recorded history. This method uses the history of scheduled operations recorded by the - `HistoryTracker` to create a GIF that shows the progression of the - scheduling process. + :class:`HistoryTracker` to create a GIF that shows the progression of + the scheduling process. The GIF creation process involves: + - Using the history of scheduled operations to generate frames for each step of the schedule. - Creating a GIF from these frames. - Optionally, removing the frames after the GIF is created. The configuration for the GIF creation can be customized through the - `gif_config` attribute. + ``gif_config`` attribute. """ create_gantt_chart_gif( instance=self.history_observer.dispatcher.instance, schedule_history=self.history_observer.history, - plot_function=self.plot_function, + plot_function=self.partial_gantt_chart_plotter, **self.gif_config ) @@ -208,12 +246,12 @@ def create_video(self) -> None: history. This method uses the history of scheduled operations recorded by the - `HistoryTracker` to create a video that shows the progression of the - scheduling process. + :class:`HistoryTracker` to create a video that shows the progression + of the scheduling process. """ create_gantt_chart_video( instance=self.history_observer.dispatcher.instance, schedule_history=self.history_observer.history, - plot_function=self.plot_function, + plot_function=self.partial_gantt_chart_plotter, **self.video_config ) diff --git a/job_shop_lib/visualization/_gantt_chart_video_and_gif_creation.py b/job_shop_lib/visualization/_gantt_chart_video_and_gif_creation.py index 1f38591..0c722b4 100644 --- a/job_shop_lib/visualization/_gantt_chart_video_and_gif_creation.py +++ b/job_shop_lib/visualization/_gantt_chart_video_and_gif_creation.py @@ -38,16 +38,6 @@ class PartialGanttChartPlotter(Protocol): This kind of functions are created using the :func:`plot_gantt_chart_wrapper` function. - - The function should take the following arguments: - - - schedule: The schedule to plot. - - makespan: The makespan of the schedule if known. Can be used to fix the - x-axis limits. - - available_operations: A list of available operations. If ``None``, - the available operations are not shown. - - current_time: The current time in the schedule. If provided, a red - vertical line is plotted at this time. """ def __call__( @@ -57,7 +47,21 @@ def __call__( available_operations: list[Operation] | None = None, current_time: int | None = None, ) -> Figure: - pass + """Plots a Gantt chart for an unfinished schedule. + + Args: + schedule: + The schedule to plot. + makespan: + The makespan of the schedule if known. Can be used to fix + the x-axis limits. + available_operations: + A list of available operations. If ``None``, + the available operations are not shown. + current_time: + The current time in the schedule. If provided, a red + vertical line is plotted at this time. + """ def get_partial_gantt_chart_plotter( diff --git a/job_shop_lib/visualization/_agent_task_graph.py b/job_shop_lib/visualization/_plot_agent_task_graph.py similarity index 100% rename from job_shop_lib/visualization/_agent_task_graph.py rename to job_shop_lib/visualization/_plot_agent_task_graph.py diff --git a/job_shop_lib/visualization/_plot_disjunctive_graph.py b/job_shop_lib/visualization/_plot_disjunctive_graph.py new file mode 100644 index 0000000..2eda54f --- /dev/null +++ b/job_shop_lib/visualization/_plot_disjunctive_graph.py @@ -0,0 +1,382 @@ +"""Module for visualizing the disjunctive graph of a job shop instance.""" + +import functools +from typing import Any +from collections.abc import Callable, Sequence, Iterable +import warnings +import copy + +import matplotlib +import matplotlib.pyplot as plt +import networkx as nx +from networkx.drawing.nx_agraph import graphviz_layout + +from job_shop_lib import JobShopInstance +from job_shop_lib.graphs import ( + JobShopGraph, + EdgeType, + NodeType, + Node, + build_disjunctive_graph, +) +from job_shop_lib.exceptions import ValidationError + + +Layout = Callable[[nx.Graph], dict[str, tuple[float, float]]] + + +def duration_labeler(node: Node) -> str: + """Returns a label for the node with the processing time. + + In the form ``"$p_{ij}=duration$"``, where $i$ is the job id and $j$ is + the position in the job. + + Args: + node: + The operation node to label. See + :class:`~job_shop_lib.graphs.Node`. + """ + return ( + f"$p_{{{node.operation.job_id + 1}" + f"{node.operation.position_in_job + 1}}}={node.operation.duration}$" + ) + + +# This function could be improved by a function extraction refactoring +# (see `plot_gantt_chart` +# function as a reference in how to do it). That would solve the +# "too many locals" warning. However, this refactoring is not a priority at +# the moment. To compensate, sections are separated by comments. +# For the "too many arguments" warning no satisfactory solution was +# found. I believe is still better than using `**kwargs` and losing the +# function signature or adding a dataclass for configuration (it would add +# unnecessary complexity). A TypedDict could be used too, but the default +# values would not be explicit. +# pylint: disable=too-many-arguments, too-many-locals, too-many-statements +# pylint: disable=too-many-branches, line-too-long +def plot_disjunctive_graph( + job_shop: JobShopGraph | JobShopInstance, + *, + title: str | None = None, + figsize: tuple[float, float] = (6, 4), + node_size: int = 1600, + edge_width: int = 2, + font_size: int = 10, + arrow_size: int = 35, + alpha: float = 0.95, + operation_node_labeler: Callable[[Node], str] = duration_labeler, + node_font_color: str = "white", + color_map: str = "Dark2_r", + disjunctive_edge_color: str = "red", + conjunctive_edge_color: str = "black", + layout: Layout | None = None, + draw_disjunctive_edges: bool | str = True, + conjunctive_edges_additional_params: dict[str, Any] | None = None, + disjunctive_edges_additional_params: dict[str, Any] | None = None, + conjunctive_patch_label: str = "Conjunctive edges", + disjunctive_patch_label: str = "Disjunctive edges", + legend_text: str = "$p_{ij}=$duration of $O_{ij}$", + show_machine_colors_in_legend: bool = True, + machine_labels: Sequence[str] | None = None, + legend_location: str = "upper left", + legend_bbox_to_anchor: tuple[float, float] = (1.01, 1), + start_node_label: str = "$S$", + end_node_label: str = "$T$", + font_family: str = "sans-serif", +) -> tuple[plt.Figure, plt.Axes]: + r"""Plots the disjunctive graph of the given job shop instance or graph. + + Args: + job_shop: + The job shop instance or graph to plot. Can be either a + :class:`JobShopGraph` or a :class:`JobShopInstance`. If a job shop + instance is given, the disjunctive graph is built before plotting + using the :func:`~job_shop_lib.graphs.build_disjunctive_graph`. + title: + The title of the graph (default is ``"Disjunctive Graph + Visualization: {job_shop.instance.name}"``). + figsize: + The size of the figure (default is (6, 4)). + node_size: + The size of the nodes (default is 1600). + edge_width: + The width of the edges (default is 2). + font_size: + The font size of the node labels (default is 10). + arrow_size: + The size of the arrows (default is 35). + alpha: + The transparency level of the nodes and edges (default is 0.95). + operation_node_labeler: + A function that formats labels for operation nodes. Receives a + :class:`~job_shop_lib.graphs.Node` and returns a string. + The default is :func:`duration_labeler`, which labels the nodes + with their duration. + node_font_color: + The color of the node labels (default is ``"white"``). + color_map: + The color map to use for the nodes (default is ``"Dark2_r"``). + disjunctive_edge_color: + The color of the disjunctive edges (default is ``"red"``). + conjunctive_edge_color: + The color of the conjunctive edges (default is ``"black"``). + layout: + The layout of the graph (default is ``graphviz_layout`` with + ``prog="dot"`` and ``args="-Grankdir=LR"``). If not available, + the spring layout is used. To install pygraphviz, check + `pygraphviz documentation + `_. + draw_disjunctive_edges: + Whether to draw disjunctive edges (default is ``True``). If + ``False``, only conjunctive edges are drawn. If ``"single_edge",`` + the disjunctive edges are drawn as undirected edges by removing one + of the directions. If using this last option is recommended to set + the "arrowstyle" parameter to ``"-"`` or ``"<->"`` in the + ``disjunctive_edges_additional_params`` to make the edges look + better. See `matplotlib documentation on arrow styles `_ + and `nx.draw_networkx_edges `_ + for more information. + conjunctive_edges_additional_params: + Additional parameters to pass to the conjunctive edges when + drawing them (default is ``None``). See the documentation of + `nx.draw_networkx_edges `_ + for more information. The parameters that are explicitly set by + this function and should not be part of this dictionary are + ``edgelist``, ``pos``, ``width``, ``edge_color``, and + ``arrowsize``. + disjunctive_edges_additional_params: + Same as ``conjunctive_edges_additional_params``, but for + disjunctive edges (default is ``None``). + conjunctive_patch_label: + The label for the conjunctive edges in the legend (default is + ``"Conjunctive edges"``). + disjunctive_patch_label: + The label for the disjunctive edges in the legend (default is + ``"Disjunctive edges"``). + legend_text: + Text to display in the legend after the conjunctive and + disjunctive edges labels (default is + ``"$p_{ij}=$duration of $O_{ij}$"``). + show_machine_colors_in_legend: + Whether to show the colors of the machines in the legend + (default is ``True``). + machine_labels: + The labels for the machines (default is + ``[f"Machine {i}" for i in range(num_machines)]``). Not used if + ``show_machine_colors_in_legend`` is ``False``. + legend_location: + The location of the legend (default is "upper left"). + legend_bbox_to_anchor: + The anchor of the legend box (default is ``(1.01, 1)``). + start_node_label: + The label for the start node (default is ``"$S$"``). + end_node_label: + The label for the end node (default is ``"$T$"``). + font_family: + The font family of the node labels (default is ``"sans-serif"``). + + Returns: + A matplotlib Figure object representing the disjunctive graph. + + Example: + + .. code-block:: python + + job_shop_instance = JobShopInstance(...) # or a JobShopGraph + fig = plot_disjunctive_graph(job_shop_instance) + + """ # noqa: E501 + + if isinstance(job_shop, JobShopInstance): + job_shop_graph = build_disjunctive_graph(job_shop) + else: + job_shop_graph = job_shop + + # Set up the plot + # ---------------- + plt.figure(figsize=figsize) + if title is None: + title = ( + f"Disjunctive Graph Visualization: {job_shop_graph.instance.name}" + ) + plt.title(title) + + # Set up the layout + # ----------------- + if layout is None: + layout = functools.partial( + graphviz_layout, prog="dot", args="-Grankdir=LR" + ) + + temp_graph = copy.deepcopy(job_shop_graph.graph) + # Remove disjunctive edges to get a better layout + temp_graph.remove_edges_from( + [ + (u, v) + for u, v, d in job_shop_graph.graph.edges(data=True) + if d["type"] == EdgeType.DISJUNCTIVE + ] + ) + + try: + pos = layout(temp_graph) + except ImportError: + warnings.warn( + "Default layout requires pygraphviz http://pygraphviz.github.io/. " + "Using spring layout instead.", + ) + pos = nx.spring_layout(temp_graph) + + # Draw nodes + # ---------- + node_colors = [ + _get_node_color(node) + for node in job_shop_graph.nodes + if not job_shop_graph.is_removed(node.node_id) + ] + cmap_func = matplotlib.colormaps.get_cmap(color_map) + nx.draw_networkx_nodes( + job_shop_graph.graph, + pos, + node_size=node_size, + node_color=node_colors, + alpha=alpha, + cmap=cmap_func, + ) + + # Draw edges + # ---------- + conjunctive_edges = [ + (u, v) + for u, v, d in job_shop_graph.graph.edges(data=True) + if d["type"] == EdgeType.CONJUNCTIVE + ] + disjunctive_edges: Iterable[tuple[int, int]] = [ + (u, v) + for u, v, d in job_shop_graph.graph.edges(data=True) + if d["type"] == EdgeType.DISJUNCTIVE + ] + if conjunctive_edges_additional_params is None: + conjunctive_edges_additional_params = {} + if disjunctive_edges_additional_params is None: + disjunctive_edges_additional_params = {} + + nx.draw_networkx_edges( + job_shop_graph.graph, + pos, + edgelist=conjunctive_edges, + width=edge_width, + edge_color=conjunctive_edge_color, + arrowsize=arrow_size, + **conjunctive_edges_additional_params, + ) + + if draw_disjunctive_edges: + if draw_disjunctive_edges == "single_edge": + # Filter the disjunctive edges to remove one of the directions + disjunctive_edges_filtered = set() + for u, v in disjunctive_edges: + if u > v: + u, v = v, u + disjunctive_edges_filtered.add((u, v)) + disjunctive_edges = disjunctive_edges_filtered + nx.draw_networkx_edges( + job_shop_graph.graph, + pos, + edgelist=disjunctive_edges, + width=edge_width, + edge_color=disjunctive_edge_color, + arrowsize=arrow_size, + **disjunctive_edges_additional_params, + ) + + # Draw node labels + # ---------------- + operation_nodes = job_shop_graph.nodes_by_type[NodeType.OPERATION] + labels = {} + source_node = job_shop_graph.nodes_by_type[NodeType.SOURCE][0] + labels[source_node] = start_node_label + + sink_node = job_shop_graph.nodes_by_type[NodeType.SINK][0] + labels[sink_node] = end_node_label + machine_colors: dict[int, tuple[float, float, float, float]] = {} + for operation_node in operation_nodes: + if job_shop_graph.is_removed(operation_node.node_id): + continue + labels[operation_node] = operation_node_labeler(operation_node) + machine_id = operation_node.operation.machine_id + if machine_id not in machine_colors: + machine_colors[machine_id] = cmap_func( + (_get_node_color(operation_node) + 1) + / job_shop_graph.instance.num_machines + ) + + nx.draw_networkx_labels( + job_shop_graph.graph, + pos, + labels=labels, + font_color=node_font_color, + font_size=font_size, + font_family=font_family, + ) + # Final touches + # ------------- + plt.axis("off") + plt.tight_layout() + # Create a legend to indicate the meaning of the edge colors + conjunctive_patch = matplotlib.patches.Patch( + color=conjunctive_edge_color, label=conjunctive_patch_label + ) + disjunctive_patch = matplotlib.patches.Patch( + color=disjunctive_edge_color, label=disjunctive_patch_label + ) + handles = [conjunctive_patch, disjunctive_patch] + + # Add machine colors to the legend + if show_machine_colors_in_legend: + machine_patches = [ + matplotlib.patches.Patch( + color=color, + label=( + machine_labels[machine_id] + if machine_labels is not None + else f"Machine {machine_id}" + ), + ) + for machine_id, color in machine_colors.items() + ] + handles.extend(machine_patches) + + # Add to the legend the meaning of m and d + if legend_text: + extra = matplotlib.patches.Rectangle( + (0, 0), + 1, + 1, + fc="w", + fill=False, + edgecolor="none", + linewidth=0, + label=legend_text, + ) + handles.append(extra) + + plt.legend( + handles=handles, + loc=legend_location, + bbox_to_anchor=legend_bbox_to_anchor, + borderaxespad=0.0, + ) + return plt.gcf(), plt.gca() + + +def _get_node_color(node: Node) -> int: + """Returns the color of the node.""" + if node.node_type == NodeType.SOURCE: + return -1 + if node.node_type == NodeType.SINK: + return -1 + if node.node_type == NodeType.OPERATION: + return node.operation.machine_id + + raise ValidationError("Invalid node type.") diff --git a/poetry.lock b/poetry.lock index 3c07b76..9fc4624 100644 --- a/poetry.lock +++ b/poetry.lock @@ -518,6 +518,20 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "cloudpickle" version = "3.0.0" @@ -807,6 +821,17 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "docstring-parser-fork" +version = "0.0.9" +description = "Parse Python docstrings in reST, Google and Numpydoc format" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "docstring_parser_fork-0.0.9-py3-none-any.whl", hash = "sha256:0be85ad00cb25bf5beeb673e46e777facf0f47552fa3a7570d120ef7e3374401"}, + {file = "docstring_parser_fork-0.0.9.tar.gz", hash = "sha256:95b23cc5092af85080c716a6da68360f5ae4fcffa75f4a3aca5e539783cbcc3d"}, +] + [[package]] name = "docutils" version = "0.21.2" @@ -2425,9 +2450,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -2843,8 +2868,8 @@ files = [ annotated-types = ">=0.6.0" pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -2952,6 +2977,26 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydoclint" +version = "0.5.9" +description = "A Python docstring linter that checks arguments, returns, yields, and raises sections" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydoclint-0.5.9-py2.py3-none-any.whl", hash = "sha256:089327003cef6fe5605cbaa9887859ea5229ce0c9abb52775ffd57513094c1ae"}, + {file = "pydoclint-0.5.9.tar.gz", hash = "sha256:e200f964a5d9fbbb2ff1078bd7cb5433a0564d2482b6a1ba1be848f66bc4924f"}, +] + +[package.dependencies] +click = ">=8.1.0" +docstring-parser-fork = ">=0.0.9" +flake8 = {version = ">=4", optional = true, markers = "extra == \"flake8\""} +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +flake8 = ["flake8 (>=4)"] + [[package]] name = "pyflakes" version = "3.2.0" @@ -4247,4 +4292,4 @@ pygraphviz = ["pygraphviz"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "bb56e312dadb04b58ea7eb7ad5e05c872157bd22143279f307a97b4ab8d98a46" +content-hash = "74eb55f20d69367e691ef264504eb0d1dee6dd92b6ef51ea3d341f603ef9f809" diff --git a/pyproject.toml b/pyproject.toml index 91f6e30..98037d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "job-shop-lib" -version = "1.0.0-alpha.3" +version = "1.0.0-alpha.4" description = "An easy-to-use and modular Python library for the Job Shop Scheduling Problem (JSSP)" authors = ["Pabloo22 "] license = "MIT" @@ -33,6 +33,7 @@ optional = true [tool.poetry.group.lint.dependencies] flake8 = "^7.0.0" mypy = "^1.8.0" +pydoclint = {extras = ["flake8"], version = "^0.5.9"} [tool.poetry.group.notebooks] optional = true diff --git a/tests/conftest.py b/tests/conftest.py index 6d85660..899b744 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,31 @@ def example_job_shop_instance(): return instance +@pytest.fixture +def example_2_job_shop_instance(): + # Cada máquina se representa con un id (empezando por 0) + m0 = 0 + m1 = 1 + m2 = 2 + + j0 = [ + Operation(m0, duration=2), + Operation(m1, duration=2), + Operation(m2, duration=2), + ] + j1 = [ + Operation(m0, duration=1), + Operation(m1, duration=1), + Operation(m2, duration=1), + ] + j2 = [ + Operation(m0, duration=2), + Operation(m2, duration=3), + Operation(m1, duration=3), + ] + return JobShopInstance([j0, j1, j2], name="Example 2") + + @pytest.fixture def irregular_job_shop_instance(): m1 = 0 diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 300b6c5..9689de8 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -106,6 +106,20 @@ def test_from_job_sequences(example_job_shop_instance: JobShopInstance): assert schedule.makespan() == 11 +def test_from_job_sequences_invalid( + example_job_shop_instance: JobShopInstance, +): + job_sequences = [ + [0, 1, 2], + [2, 0, 1], + [1, 0, 2], + ] + with pytest.raises(ValidationError): + Schedule.from_job_sequences( + example_job_shop_instance, job_sequences + ) + + def test_to_dict(example_job_shop_instance: JobShopInstance): job_sequences = [ [0, 2, 1], diff --git a/tests/visualization/baseline/test_plot_disjunctive_graph.png b/tests/visualization/baseline/test_plot_disjunctive_graph.png index 2cee0a0..6851f10 100644 Binary files a/tests/visualization/baseline/test_plot_disjunctive_graph.png and b/tests/visualization/baseline/test_plot_disjunctive_graph.png differ diff --git a/tests/visualization/test_plot_disjunctive_graph.py b/tests/visualization/test_plot_disjunctive_graph.py index 1504d2a..00f7e9c 100644 --- a/tests/visualization/test_plot_disjunctive_graph.py +++ b/tests/visualization/test_plot_disjunctive_graph.py @@ -1,14 +1,16 @@ import pytest from job_shop_lib.visualization import plot_disjunctive_graph -from job_shop_lib.graphs import build_disjunctive_graph @pytest.mark.mpl_image_compare( style="default", savefig_kwargs={"dpi": 300, "bbox_inches": "tight"} ) def test_plot_disjunctive_graph(example_job_shop_instance): - graph = build_disjunctive_graph(example_job_shop_instance) - fig = plot_disjunctive_graph(graph) + fig = plot_disjunctive_graph(example_job_shop_instance) return fig + + +if __name__ == "__main__": + pytest.main(["-v", __file__])