job-shop-lib 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,6 +7,7 @@ from typing import Any
7
7
  from collections.abc import Callable
8
8
  from collections import deque
9
9
  from functools import wraps
10
+ from warnings import warn
10
11
 
11
12
  from job_shop_lib import (
12
13
  JobShopInstance,
@@ -18,13 +19,45 @@ from job_shop_lib import (
18
19
 
19
20
  # Added here to avoid circular imports
20
21
  class DispatcherObserver(abc.ABC):
21
- """Interface for classes that observe the dispatcher."""
22
+ """Interface for classes that observe th"""
23
+
24
+ def __init__(
25
+ self,
26
+ dispatcher: Dispatcher,
27
+ is_singleton: bool = True,
28
+ subscribe: bool = True,
29
+ ):
30
+ """Initializes the observer with the `Dispatcher` and subscribes to
31
+ it.
32
+
33
+ Args:
34
+ subject:
35
+ The subject to observe.
36
+ is_singleton:
37
+ Whether the observer should be a singleton. If True, the
38
+ observer will be the only instance of its class in the
39
+ subject's list of subscribers. If False, the observer will
40
+ be added to the subject's list of subscribers every time
41
+ it is initialized.
42
+ subscribe:
43
+ Whether to subscribe the observer to the subject. If False,
44
+ the observer will not be subscribed to the subject and will
45
+ not receive automatic updates.
46
+ """
47
+ if is_singleton and any(
48
+ isinstance(observer, self.__class__)
49
+ for observer in dispatcher.subscribers
50
+ ):
51
+ raise ValueError(
52
+ f"An observer of type {self.__class__.__name__} already "
53
+ "exists in the dispatcher's list of subscribers. If you want "
54
+ "to create multiple instances of this observer, set "
55
+ "`is_singleton` to False."
56
+ )
22
57
 
23
- def __init__(self, dispatcher: Dispatcher):
24
- """Initializes the observer with the dispatcher and subscribes to
25
- it."""
26
58
  self.dispatcher = dispatcher
27
- self.dispatcher.subscribe(self)
59
+ if subscribe:
60
+ self.dispatcher.subscribe(self)
28
61
 
29
62
  @abc.abstractmethod
30
63
  def update(self, scheduled_operation: ScheduledOperation):
@@ -38,7 +71,7 @@ class DispatcherObserver(abc.ABC):
38
71
  return self.__class__.__name__
39
72
 
40
73
  def __repr__(self) -> str:
41
- return str(self)
74
+ return self.__class__.__name__
42
75
 
43
76
 
44
77
  def _dispatcher_cache(method):
@@ -85,8 +118,6 @@ class Dispatcher:
85
118
  pruning_function:
86
119
  A function that filters out operations that are not ready to be
87
120
  scheduled.
88
- subscribers:
89
- A list of observers that are subscribed to the dispatcher.
90
121
  """
91
122
 
92
123
  __slots__ = (
@@ -151,40 +182,6 @@ class Dispatcher:
151
182
  """Returns the next available time for each job."""
152
183
  return self._job_next_available_time
153
184
 
154
- @classmethod
155
- def create_schedule_from_raw_solution(
156
- cls, instance: JobShopInstance, raw_solution: list[list[Operation]]
157
- ) -> Schedule:
158
- """Creates a schedule from a raw solution.
159
-
160
- A raw solution is a list of lists of operations, where each list
161
- represents the order of operations for a machine.
162
-
163
- Args:
164
- instance:
165
- The instance of the job shop problem to be solved.
166
- raw_solution:
167
- A list of lists of operations, where each list represents the
168
- order of operations for a machine.
169
-
170
- Returns:
171
- A Schedule object representing the solution.
172
- """
173
- dispatcher = cls(instance)
174
- dispatcher.reset()
175
- raw_solution_deques = [
176
- deque(operations) for operations in raw_solution
177
- ]
178
- while not dispatcher.schedule.is_complete():
179
- for machine_id, operations in enumerate(raw_solution_deques):
180
- if not operations:
181
- continue
182
- operation = operations[0]
183
- if dispatcher.is_operation_ready(operation):
184
- dispatcher.dispatch(operation, machine_id)
185
- operations.popleft()
186
- return dispatcher.schedule
187
-
188
185
  def subscribe(self, observer: DispatcherObserver):
189
186
  """Subscribes an observer to the dispatcher."""
190
187
  self.subscribers.append(observer)
@@ -311,20 +308,6 @@ class Dispatcher:
311
308
  min_start_time = min(min_start_time, start_time)
312
309
  return int(min_start_time)
313
310
 
314
- @_dispatcher_cache
315
- def uncompleted_operations(self) -> list[Operation]:
316
- """Returns the list of operations that have not been scheduled.
317
-
318
- An operation is uncompleted if it has not been scheduled yet.
319
-
320
- It is more efficient than checking all operations in the instance.
321
- """
322
- uncompleted_operations = []
323
- for job_id, next_position in enumerate(self._job_next_operation_index):
324
- operations = self.instance.jobs[job_id][next_position:]
325
- uncompleted_operations.extend(operations)
326
- return uncompleted_operations
327
-
328
311
  @_dispatcher_cache
329
312
  def available_operations(self) -> list[Operation]:
330
313
  """Returns a list of available operations for processing, optionally
@@ -337,15 +320,22 @@ class Dispatcher:
337
320
  Returns:
338
321
  A list of Operation objects that are available for scheduling.
339
322
  """
340
-
341
- available_operations = self._available_operations()
323
+ available_operations = self.available_operations_without_pruning()
342
324
  if self.pruning_function is not None:
343
325
  available_operations = self.pruning_function(
344
326
  self, available_operations
345
327
  )
346
328
  return available_operations
347
329
 
348
- def _available_operations(self) -> list[Operation]:
330
+ @_dispatcher_cache
331
+ def available_operations_without_pruning(self) -> list[Operation]:
332
+ """Returns a list of available operations for processing without
333
+ applying the pruning function.
334
+
335
+ Returns:
336
+ A list of Operation objects that are available for scheduling
337
+ based on precedence and machine constraints only.
338
+ """
349
339
  available_operations = []
350
340
  for job_id, next_position in enumerate(self._job_next_operation_index):
351
341
  if next_position == len(self.instance.jobs[job_id]):
@@ -353,3 +343,174 @@ class Dispatcher:
353
343
  operation = self.instance.jobs[job_id][next_position]
354
344
  available_operations.append(operation)
355
345
  return available_operations
346
+
347
+ @_dispatcher_cache
348
+ def unscheduled_operations(self) -> list[Operation]:
349
+ """Returns the list of operations that have not been scheduled."""
350
+ unscheduled_operations = []
351
+ for job_id, next_position in enumerate(self._job_next_operation_index):
352
+ operations = self.instance.jobs[job_id][next_position:]
353
+ unscheduled_operations.extend(operations)
354
+ return unscheduled_operations
355
+
356
+ @_dispatcher_cache
357
+ def scheduled_operations(self) -> list[Operation]:
358
+ """Returns the list of operations that have been scheduled."""
359
+ scheduled_operations = []
360
+ for job_id, next_position in enumerate(self._job_next_operation_index):
361
+ operations = self.instance.jobs[job_id][:next_position]
362
+ scheduled_operations.extend(operations)
363
+ return scheduled_operations
364
+
365
+ @_dispatcher_cache
366
+ def available_machines(self) -> list[int]:
367
+ """Returns the list of available machines."""
368
+ available_operations = self.available_operations()
369
+ available_machines = set()
370
+ for operation in available_operations:
371
+ available_machines.update(operation.machines)
372
+ return list(available_machines)
373
+
374
+ @_dispatcher_cache
375
+ def available_jobs(self) -> list[int]:
376
+ """Returns the list of available jobs."""
377
+ available_operations = self.available_operations()
378
+ available_jobs = set(
379
+ operation.job_id for operation in available_operations
380
+ )
381
+ return list(available_jobs)
382
+
383
+ def earliest_start_time(self, operation: Operation) -> int:
384
+ """Calculates the earliest start time for a given operation based on
385
+ machine and job constraints.
386
+
387
+ This method is different from the `start_time` method in that it
388
+ takes into account every machine that can process the operation, not
389
+ just the one that will process it. However, it also assumes that
390
+ the operation is ready to be scheduled in the job in favor of
391
+ performance.
392
+
393
+ Args:
394
+ operation:
395
+ The operation for which to calculate the earliest start time.
396
+
397
+ Returns:
398
+ The earliest start time for the operation.
399
+ """
400
+ machine_earliest_start_time = min(
401
+ self._machine_next_available_time[machine_id]
402
+ for machine_id in operation.machines
403
+ )
404
+ job_start_time = self._job_next_available_time[operation.job_id]
405
+ return max(machine_earliest_start_time, job_start_time)
406
+
407
+ def remaining_duration(
408
+ self, scheduled_operation: ScheduledOperation
409
+ ) -> int:
410
+ """Calculates the remaining duration of a scheduled operation.
411
+
412
+ The method computes the remaining time for an operation to finish,
413
+ based on the maximum of the operation's start time or the current time.
414
+ This helps in determining how much time is left from 'now' until the
415
+ operation is completed.
416
+
417
+ Args:
418
+ scheduled_operation:
419
+ The operation for which to calculate the remaining time.
420
+
421
+ Returns:
422
+ The remaining duration.
423
+ """
424
+ adjusted_start_time = max(
425
+ scheduled_operation.start_time, self.current_time()
426
+ )
427
+ return scheduled_operation.end_time - adjusted_start_time
428
+
429
+ @_dispatcher_cache
430
+ def completed_operations(self) -> set[Operation]:
431
+ """Returns the set of operations that have been completed.
432
+
433
+ This method returns the operations that have been scheduled and the
434
+ current time is greater than or equal to the end time of the operation.
435
+ """
436
+ scheduled_operations = set(self.scheduled_operations())
437
+ ongoing_operations = set(
438
+ map(
439
+ lambda scheduled_op: scheduled_op.operation,
440
+ self.ongoing_operations(),
441
+ )
442
+ )
443
+ completed_operations = scheduled_operations - ongoing_operations
444
+ return completed_operations
445
+
446
+ @_dispatcher_cache
447
+ def uncompleted_operations(self) -> list[Operation]:
448
+ """Returns the list of operations that have not been completed yet.
449
+
450
+ This method checks for operations that either haven't been scheduled
451
+ or have been scheduled but haven't reached their completion time.
452
+
453
+ Note:
454
+ The behavior of this method changed in version 0.5.0. Previously, it
455
+ only returned unscheduled operations. For the old behavior, use the
456
+ `unscheduled_operations` method.
457
+ """
458
+ uncompleted_operations = self.unscheduled_operations()
459
+ uncompleted_operations.extend(
460
+ scheduled_operation.operation
461
+ for scheduled_operation in self.ongoing_operations()
462
+ )
463
+ return uncompleted_operations
464
+
465
+ @_dispatcher_cache
466
+ def ongoing_operations(self) -> list[ScheduledOperation]:
467
+ """Returns the list of operations that are currently being processed.
468
+
469
+ This method returns the operations that have been scheduled and are
470
+ currently being processed by the machines.
471
+ """
472
+ current_time = self.current_time()
473
+ ongoing_operations = []
474
+ for machine_schedule in self.schedule.schedule:
475
+ for scheduled_operation in reversed(machine_schedule):
476
+ is_completed = scheduled_operation.end_time <= current_time
477
+ if is_completed:
478
+ break
479
+ ongoing_operations.append(scheduled_operation)
480
+ return ongoing_operations
481
+
482
+ def is_scheduled(self, operation: Operation) -> bool:
483
+ """Checks if the given operation has been scheduled."""
484
+ job_next_op_idx = self._job_next_operation_index[operation.job_id]
485
+ return operation.position_in_job < job_next_op_idx
486
+
487
+ def is_ongoing(self, scheduled_operation: ScheduledOperation) -> bool:
488
+ """Checks if the given operation is currently being processed."""
489
+ current_time = self.current_time()
490
+ return scheduled_operation.start_time <= current_time
491
+
492
+ @classmethod
493
+ def create_schedule_from_raw_solution(
494
+ cls, instance: JobShopInstance, raw_solution: list[list[Operation]]
495
+ ) -> Schedule:
496
+ """Deprecated method, use `Schedule.from_job_sequences` instead."""
497
+ warn(
498
+ "Dispatcher.create_schedule_from_raw_solution is deprecated. "
499
+ "Use Schedule.from_job_sequences instead. It will be removed in "
500
+ "version 1.0.0.",
501
+ DeprecationWarning,
502
+ )
503
+ dispatcher = cls(instance)
504
+ dispatcher.reset()
505
+ raw_solution_deques = [
506
+ deque(operations) for operations in raw_solution
507
+ ]
508
+ while not dispatcher.schedule.is_complete():
509
+ for machine_id, operations in enumerate(raw_solution_deques):
510
+ if not operations:
511
+ continue
512
+ operation = operations[0]
513
+ if dispatcher.is_operation_ready(operation):
514
+ dispatcher.dispatch(operation, machine_id)
515
+ operations.popleft()
516
+ return dispatcher.schedule
@@ -0,0 +1,28 @@
1
+ """Contains FeatureObserver classes for observing features of the
2
+ dispatcher."""
3
+
4
+ from .feature_observer import FeatureObserver, FeatureType
5
+ from .composite_feature_observer import CompositeFeatureObserver
6
+ from .earliest_start_time_observer import EarliestStartTimeObserver
7
+ from .is_ready_observer import IsReadyObserver
8
+ from .duration_observer import DurationObserver
9
+ from .is_scheduled_observer import IsScheduledObserver
10
+ from .position_in_job_observer import PositionInJobObserver
11
+ from .remaining_operations_observer import RemainingOperationsObserver
12
+ from .is_completed_observer import IsCompletedObserver
13
+ from .factory import FeatureObserverType, feature_observer_factory
14
+
15
+ __all__ = [
16
+ "FeatureObserver",
17
+ "FeatureType",
18
+ "CompositeFeatureObserver",
19
+ "EarliestStartTimeObserver",
20
+ "IsReadyObserver",
21
+ "DurationObserver",
22
+ "IsScheduledObserver",
23
+ "PositionInJobObserver",
24
+ "RemainingOperationsObserver",
25
+ "IsCompletedObserver",
26
+ "FeatureObserverType",
27
+ "feature_observer_factory",
28
+ ]
@@ -0,0 +1,87 @@
1
+ """Home of the `CompositeFeatureObserver` class."""
2
+
3
+ from collections import defaultdict
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from job_shop_lib.dispatching import Dispatcher
8
+ from job_shop_lib.dispatching.feature_observers import (
9
+ FeatureObserver,
10
+ FeatureType,
11
+ )
12
+
13
+
14
+ class CompositeFeatureObserver(FeatureObserver):
15
+ """Aggregates features from other FeatureObserver instances subscribed to
16
+ the same `Dispatcher` by concatenating their feature matrices along the
17
+ first axis (horizontal concatenation).
18
+
19
+ Attributes:
20
+ feature_observers:
21
+ List of `FeatureObserver` instances to aggregate features from.
22
+ column_names:
23
+ Dictionary mapping `FeatureType` to a list of column names for the
24
+ corresponding feature matrix. Column names are generated based on
25
+ the class name of the `FeatureObserver` instance that produced the
26
+ feature.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ dispatcher: Dispatcher,
32
+ feature_observers: list[FeatureObserver] | None = None,
33
+ subscribe: bool = True,
34
+ ):
35
+ if feature_observers is None:
36
+ feature_observers = [
37
+ observer
38
+ for observer in dispatcher.subscribers
39
+ if isinstance(observer, FeatureObserver)
40
+ ]
41
+ self.feature_observers = feature_observers
42
+ self.column_names: dict[FeatureType, list[str]] = defaultdict(list)
43
+ super().__init__(dispatcher, subscribe=subscribe)
44
+ self._set_column_names()
45
+
46
+ @property
47
+ def features_as_dataframe(self) -> dict[FeatureType, pd.DataFrame]:
48
+ """Returns the features as a dictionary of `pd.DataFrame` instances."""
49
+ return {
50
+ feature_type: pd.DataFrame(
51
+ feature_matrix, columns=self.column_names[feature_type]
52
+ )
53
+ for feature_type, feature_matrix in self.features.items()
54
+ }
55
+
56
+ def initialize_features(self):
57
+ features: dict[FeatureType, list[np.ndarray]] = defaultdict(list)
58
+ for observer in self.feature_observers:
59
+ for feature_type, feature_matrix in observer.features.items():
60
+ features[feature_type].append(feature_matrix)
61
+
62
+ self.features = {
63
+ feature_type: np.concatenate(features, axis=1)
64
+ for feature_type, features in features.items()
65
+ }
66
+
67
+ def _set_column_names(self):
68
+ for observer in self.feature_observers:
69
+ for feature_type, feature_matrix in observer.features.items():
70
+ feature_name = observer.__class__.__name__.replace(
71
+ "Observer", ""
72
+ )
73
+ if feature_matrix.shape[1] > 1:
74
+ self.column_names[feature_type] += [
75
+ f"{feature_name}_{i}"
76
+ for i in range(feature_matrix.shape[1])
77
+ ]
78
+ else:
79
+ self.column_names[feature_type].append(feature_name)
80
+
81
+ def __str__(self):
82
+ out = [f"{self.__class__.__name__}:"]
83
+ out.append("-" * (len(out[0]) - 1))
84
+ for feature_type, dataframe in self.features_as_dataframe.items():
85
+ out.append(f"{feature_type.value}:")
86
+ out.append(dataframe.to_string())
87
+ return "\n".join(out)
@@ -0,0 +1,95 @@
1
+ """Home of the `DurationObserver` class."""
2
+
3
+ import numpy as np
4
+
5
+ from job_shop_lib.dispatching import Dispatcher
6
+ from job_shop_lib import ScheduledOperation
7
+ from job_shop_lib.dispatching.feature_observers import (
8
+ FeatureObserver,
9
+ FeatureType,
10
+ )
11
+
12
+
13
+ class DurationObserver(FeatureObserver):
14
+ """Measures the remaining duration of operations, machines, and jobs.
15
+
16
+ The duration of an Operation is:
17
+ - if the operation has not been scheduled, it is the duration of the
18
+ operation.
19
+ - if the operation has been scheduled, it is the remaining duration of
20
+ the operation.
21
+ - if the operation has been completed, it is the last duration of the
22
+ operation that has been computed. The duration must be set to 0
23
+ manually if needed. We do not update the duration of completed
24
+ operations to save computation time.
25
+
26
+ The duration of a Machine or Job is the sum of the durations of the
27
+ unscheduled operations that belong to the machine or job.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ dispatcher: Dispatcher,
33
+ feature_types: list[FeatureType] | FeatureType | None = None,
34
+ subscribe: bool = True,
35
+ ):
36
+ super().__init__(
37
+ dispatcher, feature_types, feature_size=1, subscribe=subscribe
38
+ )
39
+
40
+ def initialize_features(self):
41
+ mapping = {
42
+ FeatureType.OPERATIONS: self._initialize_operation_durations,
43
+ FeatureType.MACHINES: self._initialize_machine_durations,
44
+ FeatureType.JOBS: self._initialize_job_durations,
45
+ }
46
+ for feature_type in self.features:
47
+ mapping[feature_type]()
48
+
49
+ def update(self, scheduled_operation: ScheduledOperation):
50
+ mapping = {
51
+ FeatureType.OPERATIONS: self._update_operation_durations,
52
+ FeatureType.MACHINES: self._update_machine_durations,
53
+ FeatureType.JOBS: self._update_job_durations,
54
+ }
55
+ for feature_type in self.features:
56
+ mapping[feature_type](scheduled_operation)
57
+
58
+ def _initialize_operation_durations(self):
59
+ duration_matrix = self.dispatcher.instance.durations_matrix_array
60
+ operation_durations = np.array(duration_matrix).reshape(-1, 1)
61
+ # Drop the NaN values
62
+ operation_durations = operation_durations[
63
+ ~np.isnan(operation_durations)
64
+ ].reshape(-1, 1)
65
+ self.features[FeatureType.OPERATIONS] = operation_durations
66
+
67
+ def _initialize_machine_durations(self):
68
+ machine_durations = self.dispatcher.instance.machine_loads
69
+ for machine_id, machine_load in enumerate(machine_durations):
70
+ self.features[FeatureType.MACHINES][machine_id, 0] = machine_load
71
+
72
+ def _initialize_job_durations(self):
73
+ job_durations = self.dispatcher.instance.job_durations
74
+ for job_id, job_duration in enumerate(job_durations):
75
+ self.features[FeatureType.JOBS][job_id, 0] = job_duration
76
+
77
+ def _update_operation_durations(
78
+ self, scheduled_operation: ScheduledOperation
79
+ ):
80
+ operation_id = scheduled_operation.operation.operation_id
81
+ self.features[FeatureType.OPERATIONS][operation_id, 0] = (
82
+ self.dispatcher.remaining_duration(scheduled_operation)
83
+ )
84
+
85
+ def _update_machine_durations(
86
+ self, scheduled_operation: ScheduledOperation
87
+ ):
88
+ machine_id = scheduled_operation.machine_id
89
+ op_duration = scheduled_operation.operation.duration
90
+ self.features[FeatureType.MACHINES][machine_id, 0] -= op_duration
91
+
92
+ def _update_job_durations(self, scheduled_operation: ScheduledOperation):
93
+ operation_duration = scheduled_operation.operation.duration
94
+ job_id = scheduled_operation.job_id
95
+ self.features[FeatureType.JOBS][job_id, 0] -= operation_duration