job-shop-lib 1.3.0__py3-none-any.whl → 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- job_shop_lib/__init__.py +1 -1
- job_shop_lib/_base_solver.py +7 -7
- job_shop_lib/_job_shop_instance.py +158 -26
- job_shop_lib/_operation.py +48 -5
- job_shop_lib/_schedule.py +137 -18
- job_shop_lib/constraint_programming/_ortools_solver.py +12 -13
- job_shop_lib/dispatching/_dispatcher.py +11 -5
- job_shop_lib/dispatching/_ready_operation_filters.py +2 -0
- job_shop_lib/dispatching/feature_observers/__init__.py +3 -0
- job_shop_lib/dispatching/feature_observers/_dates_observer.py +162 -0
- job_shop_lib/dispatching/feature_observers/_duration_observer.py +6 -5
- job_shop_lib/dispatching/rules/__init__.py +6 -0
- job_shop_lib/dispatching/rules/_dispatching_rule_factory.py +5 -0
- job_shop_lib/dispatching/rules/_dispatching_rules_functions.py +31 -0
- job_shop_lib/generation/_utils.py +1 -3
- job_shop_lib/metaheuristics/__init__.py +61 -0
- job_shop_lib/metaheuristics/_job_shop_annealer.py +229 -0
- job_shop_lib/metaheuristics/_neighbor_generators.py +182 -0
- job_shop_lib/metaheuristics/_objective_functions.py +73 -0
- job_shop_lib/metaheuristics/_simulated_annealing_solver.py +163 -0
- {job_shop_lib-1.3.0.dist-info → job_shop_lib-1.6.0.dist-info}/METADATA +21 -32
- {job_shop_lib-1.3.0.dist-info → job_shop_lib-1.6.0.dist-info}/RECORD +24 -18
- {job_shop_lib-1.3.0.dist-info → job_shop_lib-1.6.0.dist-info}/LICENSE +0 -0
- {job_shop_lib-1.3.0.dist-info → job_shop_lib-1.6.0.dist-info}/WHEEL +0 -0
@@ -25,9 +25,8 @@ def no_setup_time_calculator(
|
|
25
25
|
) -> int:
|
26
26
|
"""Default start time calculator that implements the standard behavior.
|
27
27
|
|
28
|
-
The start time is the maximum of the next available time
|
29
|
-
|
30
|
-
operation belongs.
|
28
|
+
The start time is the maximum of the machine's next available time, the
|
29
|
+
job's next available time, and the operation's release date.
|
31
30
|
|
32
31
|
Args:
|
33
32
|
dispatcher:
|
@@ -44,6 +43,7 @@ def no_setup_time_calculator(
|
|
44
43
|
return max(
|
45
44
|
dispatcher.machine_next_available_time[machine_id],
|
46
45
|
dispatcher.job_next_available_time[operation.job_id],
|
46
|
+
operation.release_date,
|
47
47
|
)
|
48
48
|
|
49
49
|
|
@@ -552,7 +552,9 @@ class Dispatcher:
|
|
552
552
|
for machine_id in operation.machines
|
553
553
|
)
|
554
554
|
job_start_time = self._job_next_available_time[operation.job_id]
|
555
|
-
return max(
|
555
|
+
return max(
|
556
|
+
machine_earliest_start_time, job_start_time, operation.release_date
|
557
|
+
)
|
556
558
|
|
557
559
|
def remaining_duration(
|
558
560
|
self, scheduled_operation: ScheduledOperation
|
@@ -632,7 +634,11 @@ class Dispatcher:
|
|
632
634
|
def is_ongoing(self, scheduled_operation: ScheduledOperation) -> bool:
|
633
635
|
"""Checks if the given operation is currently being processed."""
|
634
636
|
current_time = self.current_time()
|
635
|
-
return
|
637
|
+
return (
|
638
|
+
scheduled_operation.start_time
|
639
|
+
<= current_time
|
640
|
+
< scheduled_operation.end_time
|
641
|
+
)
|
636
642
|
|
637
643
|
def next_operation(self, job_id: int) -> Operation:
|
638
644
|
"""Returns the next operation to be scheduled for the given job.
|
@@ -88,6 +88,8 @@ def filter_non_immediate_operations(
|
|
88
88
|
min_start_time = dispatcher.min_start_time(operations)
|
89
89
|
immediate_operations: list[Operation] = []
|
90
90
|
for operation in operations:
|
91
|
+
if operation.release_date > min_start_time:
|
92
|
+
continue
|
91
93
|
start_time = dispatcher.earliest_start_time(operation)
|
92
94
|
if start_time == min_start_time:
|
93
95
|
immediate_operations.append(operation)
|
@@ -14,6 +14,7 @@ dispatcher.
|
|
14
14
|
PositionInJobObserver
|
15
15
|
RemainingOperationsObserver
|
16
16
|
IsCompletedObserver
|
17
|
+
DatesObserver
|
17
18
|
FeatureObserverType
|
18
19
|
feature_observer_factory
|
19
20
|
FeatureObserverConfig
|
@@ -41,6 +42,7 @@ from ._is_scheduled_observer import IsScheduledObserver
|
|
41
42
|
from ._position_in_job_observer import PositionInJobObserver
|
42
43
|
from ._remaining_operations_observer import RemainingOperationsObserver
|
43
44
|
from ._is_completed_observer import IsCompletedObserver
|
45
|
+
from ._dates_observer import DatesObserver
|
44
46
|
from ._factory import (
|
45
47
|
FeatureObserverType,
|
46
48
|
feature_observer_factory,
|
@@ -60,6 +62,7 @@ __all__ = [
|
|
60
62
|
"PositionInJobObserver",
|
61
63
|
"RemainingOperationsObserver",
|
62
64
|
"IsCompletedObserver",
|
65
|
+
"DatesObserver",
|
63
66
|
"FeatureObserverType",
|
64
67
|
"feature_observer_factory",
|
65
68
|
"FeatureObserverConfig",
|
@@ -0,0 +1,162 @@
|
|
1
|
+
"""Home of the `DatesObserver` class."""
|
2
|
+
|
3
|
+
from typing import Literal
|
4
|
+
|
5
|
+
import numpy as np
|
6
|
+
|
7
|
+
from job_shop_lib import ScheduledOperation, JobShopInstance
|
8
|
+
from job_shop_lib.dispatching import Dispatcher
|
9
|
+
from job_shop_lib.dispatching.feature_observers import (
|
10
|
+
FeatureObserver,
|
11
|
+
FeatureType,
|
12
|
+
)
|
13
|
+
|
14
|
+
Attribute = Literal["release_date", "deadline", "due_date"]
|
15
|
+
|
16
|
+
|
17
|
+
class DatesObserver(FeatureObserver):
|
18
|
+
"""Observes time-related attributes of operations.
|
19
|
+
|
20
|
+
This observer tracks attributes like release date, deadline, and
|
21
|
+
due date for each operation in the job shop instance. The attributes to
|
22
|
+
be observed can be specified during initialization.
|
23
|
+
|
24
|
+
The values are stored in a numpy array of shape ``(num_operations,
|
25
|
+
num_attributes)``, where ``num_attributes`` is the number of attributes
|
26
|
+
being observed.
|
27
|
+
|
28
|
+
All attributes are updated based on the current time of the dispatcher so
|
29
|
+
that they reflect the relative time remaining until the event occurs:
|
30
|
+
(attribute - current_time). This means that the values will be negative if
|
31
|
+
the event is in the past.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
dispatcher:
|
35
|
+
The :class:`~job_shop_lib.dispatching.Dispatcher` to observe.
|
36
|
+
subscribe:
|
37
|
+
If ``True``, the observer is subscribed to the dispatcher upon
|
38
|
+
initialization. Otherwise, the observer must be subscribed later
|
39
|
+
or manually updated.
|
40
|
+
attributes_to_observe:
|
41
|
+
A list of attributes to observe. If ``None``, all available time
|
42
|
+
attributes (release_date, deadline, due_date) will be
|
43
|
+
observed, provided they exist in the instance.
|
44
|
+
"""
|
45
|
+
|
46
|
+
_supported_feature_types = [FeatureType.OPERATIONS]
|
47
|
+
|
48
|
+
__slots__ = {
|
49
|
+
"attributes_to_observe": "List of attributes to observe.",
|
50
|
+
"_attribute_map": "Maps attributes to their column index.",
|
51
|
+
}
|
52
|
+
|
53
|
+
def __init__(
|
54
|
+
self,
|
55
|
+
dispatcher: Dispatcher,
|
56
|
+
*,
|
57
|
+
subscribe: bool = True,
|
58
|
+
attributes_to_observe: list[Attribute] | None = None,
|
59
|
+
feature_types: FeatureType | list[FeatureType] | None = None,
|
60
|
+
):
|
61
|
+
self.attributes_to_observe = self._determine_attributes_to_observe(
|
62
|
+
dispatcher.instance, attributes_to_observe
|
63
|
+
)
|
64
|
+
self._attribute_map = {
|
65
|
+
attr: i for i, attr in enumerate(self.attributes_to_observe)
|
66
|
+
}
|
67
|
+
self._previous_current_time = 0
|
68
|
+
super().__init__(
|
69
|
+
dispatcher,
|
70
|
+
subscribe=subscribe,
|
71
|
+
feature_types=feature_types,
|
72
|
+
)
|
73
|
+
|
74
|
+
@property
|
75
|
+
def feature_sizes(self) -> dict[FeatureType, int]:
|
76
|
+
return {
|
77
|
+
FeatureType.OPERATIONS: len(self.attributes_to_observe),
|
78
|
+
}
|
79
|
+
|
80
|
+
@property
|
81
|
+
def attribute_map(self) -> dict[Attribute, int]:
|
82
|
+
"""Maps attributes to their column index in the features array."""
|
83
|
+
return self._attribute_map
|
84
|
+
|
85
|
+
def initialize_features(self):
|
86
|
+
"""Initializes the features for the operations.
|
87
|
+
|
88
|
+
This method sets up the features for the operations based on the
|
89
|
+
attributes to observe. It creates a numpy array with the shape
|
90
|
+
(num_operations, num_attributes) and fills it with the corresponding
|
91
|
+
values from the job shop instance. Note that the matrix may contain
|
92
|
+
``np.nan`` values for operations that do not have deadlines or
|
93
|
+
due dates.
|
94
|
+
|
95
|
+
.. seealso::
|
96
|
+
- :meth:`job_shop_lib.JobShopInstance.release_dates_matrix_array`
|
97
|
+
- :meth:`job_shop_lib.JobShopInstance.deadlines_matrix_array`
|
98
|
+
- :meth:`job_shop_lib.JobShopInstance.due_dates_matrix_array`
|
99
|
+
"""
|
100
|
+
self.features = {
|
101
|
+
FeatureType.OPERATIONS: np.zeros(
|
102
|
+
(
|
103
|
+
self.dispatcher.instance.num_operations,
|
104
|
+
len(self.attributes_to_observe),
|
105
|
+
),
|
106
|
+
dtype=np.float32,
|
107
|
+
)
|
108
|
+
}
|
109
|
+
self._previous_current_time = self.dispatcher.current_time()
|
110
|
+
release_dates_matrix = (
|
111
|
+
self.dispatcher.instance.release_dates_matrix_array
|
112
|
+
)
|
113
|
+
valid_operations_mask = ~np.isnan(release_dates_matrix.flatten())
|
114
|
+
|
115
|
+
for attr, col_idx in self._attribute_map.items():
|
116
|
+
matrix = getattr(self.dispatcher.instance, f"{attr}s_matrix_array")
|
117
|
+
values = np.array(matrix).flatten()
|
118
|
+
valid_values = values[valid_operations_mask]
|
119
|
+
self.features[FeatureType.OPERATIONS][:, col_idx] = valid_values
|
120
|
+
|
121
|
+
def update(self, scheduled_operation: ScheduledOperation):
|
122
|
+
"""Updates the features based on the scheduled operation.
|
123
|
+
|
124
|
+
This method updates the features by subtracting the current time from
|
125
|
+
the initial release date, deadline, and due date attributes of the
|
126
|
+
operations.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
scheduled_operation:
|
130
|
+
The scheduled operation that has just been processed. It is
|
131
|
+
not used in this observer, but is required by the
|
132
|
+
:meth:`FeatureObserver.update` method.
|
133
|
+
"""
|
134
|
+
current_time = self.dispatcher.current_time()
|
135
|
+
elapsed_time = current_time - self._previous_current_time
|
136
|
+
self._previous_current_time = current_time
|
137
|
+
cols = [
|
138
|
+
self._attribute_map[attr]
|
139
|
+
for attr in self.attributes_to_observe
|
140
|
+
]
|
141
|
+
self.features[FeatureType.OPERATIONS][:, cols] -= elapsed_time
|
142
|
+
|
143
|
+
def _determine_attributes_to_observe(
|
144
|
+
self,
|
145
|
+
instance: JobShopInstance,
|
146
|
+
attributes_to_observe: list[Attribute] | None,
|
147
|
+
) -> list[Attribute]:
|
148
|
+
if attributes_to_observe:
|
149
|
+
return attributes_to_observe
|
150
|
+
|
151
|
+
default_attributes: list[Attribute] = []
|
152
|
+
if instance.has_release_dates:
|
153
|
+
default_attributes.append("release_date")
|
154
|
+
if instance.has_deadlines:
|
155
|
+
default_attributes.append("deadline")
|
156
|
+
if instance.has_due_dates:
|
157
|
+
default_attributes.append("due_date")
|
158
|
+
return default_attributes
|
159
|
+
|
160
|
+
def reset(self):
|
161
|
+
"""Calls :meth:`initialize_features`"""
|
162
|
+
self.initialize_features()
|
@@ -77,12 +77,13 @@ class DurationObserver(FeatureObserver):
|
|
77
77
|
self.features[FeatureType.JOBS][job_id, 0] = job_duration
|
78
78
|
|
79
79
|
def _update_operation_durations(
|
80
|
-
self,
|
80
|
+
self, unused_scheduled_operation: ScheduledOperation
|
81
81
|
):
|
82
|
-
|
83
|
-
|
84
|
-
self.
|
85
|
-
|
82
|
+
for scheduled_operation in self.dispatcher.ongoing_operations():
|
83
|
+
operation_id = scheduled_operation.operation.operation_id
|
84
|
+
self.features[FeatureType.OPERATIONS][operation_id, 0] = (
|
85
|
+
self.dispatcher.remaining_duration(scheduled_operation)
|
86
|
+
)
|
86
87
|
|
87
88
|
def _update_machine_durations(
|
88
89
|
self, scheduled_operation: ScheduledOperation
|
@@ -18,6 +18,7 @@ Dispatching rules:
|
|
18
18
|
:nosignatures:
|
19
19
|
|
20
20
|
shortest_processing_time_rule
|
21
|
+
largest_processing_time_rule
|
21
22
|
first_come_first_served_rule
|
22
23
|
most_work_remaining_rule
|
23
24
|
most_operations_remaining_rule
|
@@ -32,6 +33,7 @@ Dispatching rule scorers:
|
|
32
33
|
:nosignatures:
|
33
34
|
|
34
35
|
shortest_processing_time_score
|
36
|
+
largest_processing_time_score
|
35
37
|
first_come_first_served_score
|
36
38
|
MostWorkRemainingScorer
|
37
39
|
most_operations_remaining_score
|
@@ -45,6 +47,8 @@ from ._dispatching_rules_functions import (
|
|
45
47
|
most_work_remaining_rule,
|
46
48
|
most_operations_remaining_rule,
|
47
49
|
random_operation_rule,
|
50
|
+
largest_processing_time_score,
|
51
|
+
largest_processing_time_rule,
|
48
52
|
score_based_rule,
|
49
53
|
score_based_rule_with_tie_breaker,
|
50
54
|
shortest_processing_time_score,
|
@@ -78,6 +82,8 @@ __all__ = [
|
|
78
82
|
"most_work_remaining_rule",
|
79
83
|
"most_operations_remaining_rule",
|
80
84
|
"random_operation_rule",
|
85
|
+
"largest_processing_time_score",
|
86
|
+
"largest_processing_time_rule",
|
81
87
|
"score_based_rule",
|
82
88
|
"score_based_rule_with_tie_breaker",
|
83
89
|
"observer_based_most_work_remaining_rule",
|
@@ -17,6 +17,7 @@ from job_shop_lib.dispatching.rules import (
|
|
17
17
|
most_operations_remaining_rule,
|
18
18
|
random_operation_rule,
|
19
19
|
most_work_remaining_rule,
|
20
|
+
largest_processing_time_rule,
|
20
21
|
)
|
21
22
|
|
22
23
|
|
@@ -24,6 +25,7 @@ class DispatchingRuleType(str, Enum):
|
|
24
25
|
"""Enumeration of dispatching rules for the job shop scheduling problem."""
|
25
26
|
|
26
27
|
SHORTEST_PROCESSING_TIME = "shortest_processing_time"
|
28
|
+
LARGEST_PROCESSING_TIME = "largest_processing_time"
|
27
29
|
FIRST_COME_FIRST_SERVED = "first_come_first_served"
|
28
30
|
MOST_WORK_REMAINING = "most_work_remaining"
|
29
31
|
MOST_OPERATIONS_REMAINING = "most_operations_remaining"
|
@@ -62,6 +64,9 @@ def dispatching_rule_factory(
|
|
62
64
|
DispatchingRuleType.SHORTEST_PROCESSING_TIME: (
|
63
65
|
shortest_processing_time_rule
|
64
66
|
),
|
67
|
+
DispatchingRuleType.LARGEST_PROCESSING_TIME: (
|
68
|
+
largest_processing_time_rule
|
69
|
+
),
|
65
70
|
DispatchingRuleType.FIRST_COME_FIRST_SERVED: (
|
66
71
|
first_come_first_served_rule
|
67
72
|
),
|
@@ -26,6 +26,37 @@ def shortest_processing_time_rule(dispatcher: Dispatcher) -> Operation:
|
|
26
26
|
)
|
27
27
|
|
28
28
|
|
29
|
+
def largest_processing_time_rule(dispatcher: Dispatcher) -> Operation:
|
30
|
+
"""Dispatches the operation with the longest duration."""
|
31
|
+
return max(
|
32
|
+
dispatcher.available_operations(),
|
33
|
+
key=lambda operation: operation.duration,
|
34
|
+
)
|
35
|
+
|
36
|
+
|
37
|
+
def largest_processing_time_score(dispatcher: Dispatcher) -> list[int]:
|
38
|
+
"""Scores each job based on the duration of the next operation.
|
39
|
+
|
40
|
+
The score is the duration of the next operation in each job.
|
41
|
+
Jobs with longer next operations will have higher scores.
|
42
|
+
|
43
|
+
Args:
|
44
|
+
dispatcher:
|
45
|
+
The :class:`~job_shop_lib.dispatching.Dispatcher` instance
|
46
|
+
containing the job shop instance and the current state of the
|
47
|
+
schedule.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
A list of scores for each job, where the score is the duration of
|
51
|
+
the next operation in that job.
|
52
|
+
"""
|
53
|
+
num_jobs = dispatcher.instance.num_jobs
|
54
|
+
scores = [0] * num_jobs
|
55
|
+
for operation in dispatcher.available_operations():
|
56
|
+
scores[operation.job_id] = operation.duration
|
57
|
+
return scores
|
58
|
+
|
59
|
+
|
29
60
|
def first_come_first_served_rule(dispatcher: Dispatcher) -> Operation:
|
30
61
|
"""Dispatches the operation with the lowest position in job."""
|
31
62
|
return min(
|
@@ -98,9 +98,7 @@ def generate_machine_matrix_without_recirculation(
|
|
98
98
|
(num_jobs, 1),
|
99
99
|
)
|
100
100
|
# Shuffle the columns:
|
101
|
-
machine_matrix = np.apply_along_axis(
|
102
|
-
rng.permutation, 1, machine_matrix
|
103
|
-
)
|
101
|
+
machine_matrix = np.apply_along_axis(rng.permutation, 1, machine_matrix)
|
104
102
|
return machine_matrix
|
105
103
|
|
106
104
|
|
@@ -0,0 +1,61 @@
|
|
1
|
+
"""Metaheuristic algorithms for solving job shop scheduling problems.
|
2
|
+
|
3
|
+
This module provides implementations of various metaheuristic optimization
|
4
|
+
algorithms designed to solve the job shop scheduling problem.
|
5
|
+
|
6
|
+
Metaheuristics are particularly well-suited for JSSP due to their ability to:
|
7
|
+
|
8
|
+
- Handle large solution spaces efficiently
|
9
|
+
- Escape local optima through stochastic mechanisms
|
10
|
+
- Balance exploration and exploitation of the search space
|
11
|
+
- Provide good quality solutions within reasonable computational time
|
12
|
+
|
13
|
+
Currently implemented algorithms:
|
14
|
+
|
15
|
+
- Simulated annealing: A probabilistic technique that accepts worse
|
16
|
+
solutions with decreasing probability to escape local optima
|
17
|
+
|
18
|
+
The module aims to contain implementations of other
|
19
|
+
metaheuristic algorithms such as genetic algorithms, particle swarm
|
20
|
+
optimization, tabu search, etc. Feel free to open an issue if you want to
|
21
|
+
contribute!
|
22
|
+
|
23
|
+
.. autosummary::
|
24
|
+
:nosignatures:
|
25
|
+
|
26
|
+
JobShopAnnealer
|
27
|
+
SimulatedAnnealingSolver
|
28
|
+
NeighborGenerator
|
29
|
+
swap_adjacent_operations
|
30
|
+
swap_in_critical_path
|
31
|
+
swap_random_operations
|
32
|
+
ObjectiveFunction
|
33
|
+
get_makespan_with_penalties_objective
|
34
|
+
|
35
|
+
"""
|
36
|
+
|
37
|
+
from job_shop_lib.metaheuristics._objective_functions import (
|
38
|
+
ObjectiveFunction,
|
39
|
+
get_makespan_with_penalties_objective,
|
40
|
+
)
|
41
|
+
from job_shop_lib.metaheuristics._neighbor_generators import (
|
42
|
+
NeighborGenerator,
|
43
|
+
swap_adjacent_operations,
|
44
|
+
swap_in_critical_path,
|
45
|
+
swap_random_operations,
|
46
|
+
)
|
47
|
+
from job_shop_lib.metaheuristics._job_shop_annealer import JobShopAnnealer
|
48
|
+
from job_shop_lib.metaheuristics._simulated_annealing_solver import (
|
49
|
+
SimulatedAnnealingSolver,
|
50
|
+
)
|
51
|
+
|
52
|
+
__all__ = [
|
53
|
+
"JobShopAnnealer",
|
54
|
+
"SimulatedAnnealingSolver",
|
55
|
+
"NeighborGenerator",
|
56
|
+
"swap_adjacent_operations",
|
57
|
+
"swap_in_critical_path",
|
58
|
+
"swap_random_operations",
|
59
|
+
"ObjectiveFunction",
|
60
|
+
"get_makespan_with_penalties_objective",
|
61
|
+
]
|
@@ -0,0 +1,229 @@
|
|
1
|
+
import random
|
2
|
+
import math
|
3
|
+
import time
|
4
|
+
|
5
|
+
import simanneal
|
6
|
+
|
7
|
+
from job_shop_lib import JobShopInstance, Schedule
|
8
|
+
from job_shop_lib.exceptions import ValidationError
|
9
|
+
from job_shop_lib.metaheuristics import (
|
10
|
+
NeighborGenerator,
|
11
|
+
ObjectiveFunction,
|
12
|
+
swap_in_critical_path,
|
13
|
+
get_makespan_with_penalties_objective,
|
14
|
+
)
|
15
|
+
|
16
|
+
|
17
|
+
class JobShopAnnealer(simanneal.Annealer):
|
18
|
+
"""Helper class for the :class:`SimulatedAnnealingSolver`.
|
19
|
+
|
20
|
+
It uses `simanneal <https://github.com/perrygeo/simanneal>`_ as the
|
21
|
+
backend.
|
22
|
+
|
23
|
+
In the context of the job shop scheduling problem, simulated annealing is
|
24
|
+
particularly useful for improving previous solutions.
|
25
|
+
|
26
|
+
The neighbor move is pluggable via a ``neighbor_generator`` function. By
|
27
|
+
default it uses :func:`swap_in_critical_path`, but any function that takes
|
28
|
+
a schedule and a random generator and returns a new schedule can be
|
29
|
+
provided to tailor the exploration of the search space.
|
30
|
+
|
31
|
+
The process involves iteratively exploring the solution space:
|
32
|
+
|
33
|
+
1. A random move is made to alter the current state. This is done by
|
34
|
+
swapping two operations in the sequence of a machine.
|
35
|
+
2. The "energy" of the new state is evaluated using an objective function.
|
36
|
+
With the default objective function, the energy is calculated as the
|
37
|
+
makespan of the schedule plus penalties for any constraint violations
|
38
|
+
(such as deadlines and due dates). See
|
39
|
+
:func:`get_makespan_with_penalties_objective` for details. You can
|
40
|
+
create custom objective functions by implementing the
|
41
|
+
:class:`ObjectiveFunction` interface, which takes a schedule and returns
|
42
|
+
a float representing the energy of that schedule.
|
43
|
+
3. The new state is accepted if it has lower energy (a better solution).
|
44
|
+
If it has higher energy, it might still be accepted with a certain
|
45
|
+
probability, which depends on the current "temperature". The
|
46
|
+
temperature decreases over time, reducing the chance of accepting
|
47
|
+
worse solutions as the algorithm progresses. This helps to avoid
|
48
|
+
getting stuck in local optima.
|
49
|
+
|
50
|
+
This is repeated until the solution converges or a maximum number of
|
51
|
+
steps is reached.
|
52
|
+
|
53
|
+
Tuning the annealer is crucial for performance. The base
|
54
|
+
``simanneal.Annealer`` class provides parameters that can be adjusted:
|
55
|
+
|
56
|
+
- ``Tmax``: Maximum (starting) temperature (default: 25000.0).
|
57
|
+
- ``Tmin``: Minimum (ending) temperature (default: 2.5).
|
58
|
+
- ``steps``: Number of iterations (default: 50000).
|
59
|
+
- ``updates``: Number of progress updates (default: 100).
|
60
|
+
|
61
|
+
A good starting point is to set ``Tmax`` to a value that accepts about 98%
|
62
|
+
of moves and ``Tmin`` to a value where the solution no longer improves.
|
63
|
+
The number of ``steps`` should be large enough to explore the search space
|
64
|
+
thoroughly.
|
65
|
+
|
66
|
+
These parameters can be set on the annealer instance. For example:
|
67
|
+
``annealer.Tmax = 12000.0``
|
68
|
+
|
69
|
+
Alternatively, this class provides an ``auto`` method to find reasonable
|
70
|
+
parameters based on a desired runtime:
|
71
|
+
``auto_schedule = annealer.auto(minutes=1)``
|
72
|
+
``annealer.set_schedule(auto_schedule)``
|
73
|
+
|
74
|
+
Attributes:
|
75
|
+
instance:
|
76
|
+
The job shop instance to solve.
|
77
|
+
random_generator:
|
78
|
+
Random generator for reproducibility.
|
79
|
+
neighbor_generator:
|
80
|
+
Function used to generate neighbors from the current schedule.
|
81
|
+
Defaults to :func:`swap_in_critical_path`.
|
82
|
+
objective_function:
|
83
|
+
Function that computes the energy of the schedule. If ``None``,
|
84
|
+
it defaults to :func:`get_makespan_with_penalties_objective`.
|
85
|
+
This function receives a schedule and returns the energy that will
|
86
|
+
be minimized by the annealer.
|
87
|
+
|
88
|
+
Args:
|
89
|
+
instance:
|
90
|
+
The job shop instance to solve. It retrieves the jobs and
|
91
|
+
machines from the instance and uses them to create the schedule.
|
92
|
+
initial_state:
|
93
|
+
Initial state of the schedule as a list of lists, where each
|
94
|
+
sublist represents the operations of a job.
|
95
|
+
seed:
|
96
|
+
Random seed for reproducibility. If ``None``, random behavior
|
97
|
+
will be non-deterministic.
|
98
|
+
neighbor_generator:
|
99
|
+
Function that receives the current schedule and a random generator
|
100
|
+
and returns a new schedule to explore. Defaults to
|
101
|
+
:func:`swap_in_critical_path`. Use this to plug in custom
|
102
|
+
neighborhoods (e.g., adjacent swaps).
|
103
|
+
objective_function:
|
104
|
+
Function that computes the energy of the schedule. If ``None``,
|
105
|
+
it defaults to :func:`get_makespan_with_penalties_objective`.
|
106
|
+
This callable receives a :class:`~job_shop_lib.Schedule` and
|
107
|
+
returns a float that will be minimized by the annealer.
|
108
|
+
"""
|
109
|
+
|
110
|
+
copy_strategy = "method"
|
111
|
+
|
112
|
+
def __init__(
|
113
|
+
self,
|
114
|
+
instance: JobShopInstance,
|
115
|
+
initial_state: Schedule,
|
116
|
+
*,
|
117
|
+
seed: int | None = None,
|
118
|
+
neighbor_generator: NeighborGenerator = swap_in_critical_path,
|
119
|
+
objective_function: ObjectiveFunction | None = None,
|
120
|
+
):
|
121
|
+
super().__init__(initial_state)
|
122
|
+
self.instance = instance
|
123
|
+
if objective_function is None:
|
124
|
+
self.objective_function = get_makespan_with_penalties_objective()
|
125
|
+
else:
|
126
|
+
self.objective_function = objective_function
|
127
|
+
self.random_generator = random.Random(seed)
|
128
|
+
self.neighbor_generator = neighbor_generator
|
129
|
+
|
130
|
+
def _get_state(self) -> Schedule:
|
131
|
+
"""Returns the current state of the annealer.
|
132
|
+
|
133
|
+
This method facilitates type checking.
|
134
|
+
"""
|
135
|
+
return self.state
|
136
|
+
|
137
|
+
def move(self) -> None:
|
138
|
+
"""Generates a neighbor state using the configured neighbor generator.
|
139
|
+
|
140
|
+
Delegates to ``self.neighbor_generator`` with the current schedule and
|
141
|
+
the internal random generator, enabling pluggable neighborhoods.
|
142
|
+
"""
|
143
|
+
self.state = self.neighbor_generator(
|
144
|
+
self._get_state(), self.random_generator
|
145
|
+
)
|
146
|
+
|
147
|
+
def anneal(self) -> tuple[Schedule, float]:
|
148
|
+
"""Minimizes the energy of a system by simulated annealing.
|
149
|
+
|
150
|
+
Overrides the ``anneal`` method from the base class to use the
|
151
|
+
random generator defined in the constructor.
|
152
|
+
|
153
|
+
Returns:
|
154
|
+
The best state and energy found during the annealing process.
|
155
|
+
"""
|
156
|
+
step = 0
|
157
|
+
self.start = time.time()
|
158
|
+
|
159
|
+
# Precompute factor for exponential cooling from Tmax to Tmin
|
160
|
+
if self.Tmin <= 0.0:
|
161
|
+
raise ValidationError(
|
162
|
+
"Exponential cooling requires a minimum "
|
163
|
+
"temperature greater than zero."
|
164
|
+
)
|
165
|
+
t_factor = -math.log(self.Tmax / self.Tmin)
|
166
|
+
|
167
|
+
# Note initial state
|
168
|
+
t = self.Tmax
|
169
|
+
current_energy = self.energy()
|
170
|
+
prev_state = self.copy_state(self.state)
|
171
|
+
prev_energy = current_energy
|
172
|
+
self.best_state = self.copy_state(self.state)
|
173
|
+
self.best_energy = current_energy
|
174
|
+
trials, accepts, improves = 0, 0, 0
|
175
|
+
update_wave_length = 0 # not used, but avoids pylint warning
|
176
|
+
if self.updates > 0:
|
177
|
+
update_wave_length = self.steps / self.updates
|
178
|
+
self.update(step, t, current_energy, None, None)
|
179
|
+
|
180
|
+
# Attempt moves to new states
|
181
|
+
while step < self.steps and not self.user_exit:
|
182
|
+
step += 1
|
183
|
+
t = self.Tmax * math.exp(t_factor * step / self.steps)
|
184
|
+
self.move()
|
185
|
+
current_energy = self.energy()
|
186
|
+
delta_e = current_energy - prev_energy
|
187
|
+
trials += 1
|
188
|
+
if (
|
189
|
+
delta_e > 0.0
|
190
|
+
and math.exp(-delta_e / t) < self.random_generator.random()
|
191
|
+
):
|
192
|
+
# Restore previous state
|
193
|
+
self.state = self.copy_state(prev_state)
|
194
|
+
current_energy = prev_energy
|
195
|
+
else:
|
196
|
+
# Accept new state and compare to best state
|
197
|
+
accepts += 1
|
198
|
+
if delta_e < 0.0:
|
199
|
+
improves += 1
|
200
|
+
prev_state = self.copy_state(self.state)
|
201
|
+
prev_energy = current_energy
|
202
|
+
if current_energy < self.best_energy:
|
203
|
+
self.best_state = self.copy_state(self.state)
|
204
|
+
self.best_energy = current_energy
|
205
|
+
if self.updates < 1:
|
206
|
+
continue
|
207
|
+
if (step // update_wave_length) > (
|
208
|
+
(step - 1) // update_wave_length
|
209
|
+
):
|
210
|
+
self.update(
|
211
|
+
step,
|
212
|
+
t,
|
213
|
+
current_energy,
|
214
|
+
accepts / trials,
|
215
|
+
improves / trials,
|
216
|
+
)
|
217
|
+
trials, accepts, improves = 0, 0, 0
|
218
|
+
|
219
|
+
self.state = self.copy_state(self.best_state)
|
220
|
+
if self.save_state_on_exit:
|
221
|
+
self.save_state()
|
222
|
+
|
223
|
+
return self.best_state, self.best_energy
|
224
|
+
|
225
|
+
def energy(self) -> float:
|
226
|
+
"""Computes the energy of the current schedule using the objective
|
227
|
+
function provided."""
|
228
|
+
schedule = self._get_state()
|
229
|
+
return self.objective_function(schedule)
|