job-shop-lib 0.3.0__py3-none-any.whl → 0.5.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.
@@ -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