job-shop-lib 0.4.0__py3-none-any.whl → 0.5.1__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.
Files changed (27) hide show
  1. job_shop_lib/dispatching/dispatcher.py +219 -51
  2. job_shop_lib/dispatching/feature_observers/__init__.py +28 -0
  3. job_shop_lib/dispatching/feature_observers/composite_feature_observer.py +87 -0
  4. job_shop_lib/dispatching/feature_observers/duration_observer.py +95 -0
  5. job_shop_lib/dispatching/feature_observers/earliest_start_time_observer.py +156 -0
  6. job_shop_lib/dispatching/feature_observers/factory.py +58 -0
  7. job_shop_lib/dispatching/feature_observers/feature_observer.py +113 -0
  8. job_shop_lib/dispatching/feature_observers/is_completed_observer.py +98 -0
  9. job_shop_lib/dispatching/feature_observers/is_ready_observer.py +40 -0
  10. job_shop_lib/dispatching/feature_observers/is_scheduled_observer.py +34 -0
  11. job_shop_lib/dispatching/feature_observers/position_in_job_observer.py +39 -0
  12. job_shop_lib/dispatching/feature_observers/remaining_operations_observer.py +54 -0
  13. job_shop_lib/generation/__init__.py +11 -0
  14. job_shop_lib/generation/general_instance_generator.py +169 -0
  15. job_shop_lib/generation/instance_generator.py +122 -0
  16. job_shop_lib/generation/transformations.py +164 -0
  17. job_shop_lib/generators/__init__.py +2 -1
  18. job_shop_lib/generators/basic_generator.py +3 -0
  19. job_shop_lib/graphs/build_disjunctive_graph.py +20 -0
  20. job_shop_lib/job_shop_instance.py +101 -0
  21. job_shop_lib/visualization/create_gif.py +47 -38
  22. job_shop_lib/visualization/gantt_chart.py +1 -1
  23. {job_shop_lib-0.4.0.dist-info → job_shop_lib-0.5.1.dist-info}/METADATA +9 -5
  24. job_shop_lib-0.5.1.dist-info/RECORD +52 -0
  25. job_shop_lib-0.4.0.dist-info/RECORD +0 -37
  26. {job_shop_lib-0.4.0.dist-info → job_shop_lib-0.5.1.dist-info}/LICENSE +0 -0
  27. {job_shop_lib-0.4.0.dist-info → job_shop_lib-0.5.1.dist-info}/WHEEL +0 -0
@@ -19,13 +19,45 @@ from job_shop_lib import (
19
19
 
20
20
  # Added here to avoid circular imports
21
21
  class DispatcherObserver(abc.ABC):
22
- """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
+ )
23
57
 
24
- def __init__(self, dispatcher: Dispatcher):
25
- """Initializes the observer with the dispatcher and subscribes to
26
- it."""
27
58
  self.dispatcher = dispatcher
28
- self.dispatcher.subscribe(self)
59
+ if subscribe:
60
+ self.dispatcher.subscribe(self)
29
61
 
30
62
  @abc.abstractmethod
31
63
  def update(self, scheduled_operation: ScheduledOperation):
@@ -39,7 +71,7 @@ class DispatcherObserver(abc.ABC):
39
71
  return self.__class__.__name__
40
72
 
41
73
  def __repr__(self) -> str:
42
- return str(self)
74
+ return self.__class__.__name__
43
75
 
44
76
 
45
77
  def _dispatcher_cache(method):
@@ -86,8 +118,6 @@ class Dispatcher:
86
118
  pruning_function:
87
119
  A function that filters out operations that are not ready to be
88
120
  scheduled.
89
- subscribers:
90
- A list of observers that are subscribed to the dispatcher.
91
121
  """
92
122
 
93
123
  __slots__ = (
@@ -152,32 +182,6 @@ class Dispatcher:
152
182
  """Returns the next available time for each job."""
153
183
  return self._job_next_available_time
154
184
 
155
- @classmethod
156
- def create_schedule_from_raw_solution(
157
- cls, instance: JobShopInstance, raw_solution: list[list[Operation]]
158
- ) -> Schedule:
159
- """Deprecated method, use `Schedule.from_job_sequences` instead."""
160
- warn(
161
- "Dispatcher.create_schedule_from_raw_solution is deprecated. "
162
- "Use Schedule.from_job_sequences instead. It will be removed in "
163
- "version 1.0.0.",
164
- DeprecationWarning,
165
- )
166
- dispatcher = cls(instance)
167
- dispatcher.reset()
168
- raw_solution_deques = [
169
- deque(operations) for operations in raw_solution
170
- ]
171
- while not dispatcher.schedule.is_complete():
172
- for machine_id, operations in enumerate(raw_solution_deques):
173
- if not operations:
174
- continue
175
- operation = operations[0]
176
- if dispatcher.is_operation_ready(operation):
177
- dispatcher.dispatch(operation, machine_id)
178
- operations.popleft()
179
- return dispatcher.schedule
180
-
181
185
  def subscribe(self, observer: DispatcherObserver):
182
186
  """Subscribes an observer to the dispatcher."""
183
187
  self.subscribers.append(observer)
@@ -304,20 +308,6 @@ class Dispatcher:
304
308
  min_start_time = min(min_start_time, start_time)
305
309
  return int(min_start_time)
306
310
 
307
- @_dispatcher_cache
308
- def uncompleted_operations(self) -> list[Operation]:
309
- """Returns the list of operations that have not been scheduled.
310
-
311
- An operation is uncompleted if it has not been scheduled yet.
312
-
313
- It is more efficient than checking all operations in the instance.
314
- """
315
- uncompleted_operations = []
316
- for job_id, next_position in enumerate(self._job_next_operation_index):
317
- operations = self.instance.jobs[job_id][next_position:]
318
- uncompleted_operations.extend(operations)
319
- return uncompleted_operations
320
-
321
311
  @_dispatcher_cache
322
312
  def available_operations(self) -> list[Operation]:
323
313
  """Returns a list of available operations for processing, optionally
@@ -330,15 +320,22 @@ class Dispatcher:
330
320
  Returns:
331
321
  A list of Operation objects that are available for scheduling.
332
322
  """
333
-
334
- available_operations = self._available_operations()
323
+ available_operations = self.available_operations_without_pruning()
335
324
  if self.pruning_function is not None:
336
325
  available_operations = self.pruning_function(
337
326
  self, available_operations
338
327
  )
339
328
  return available_operations
340
329
 
341
- 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
+ """
342
339
  available_operations = []
343
340
  for job_id, next_position in enumerate(self._job_next_operation_index):
344
341
  if next_position == len(self.instance.jobs[job_id]):
@@ -346,3 +343,174 @@ class Dispatcher:
346
343
  operation = self.instance.jobs[job_id][next_position]
347
344
  available_operations.append(operation)
348
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
@@ -0,0 +1,156 @@
1
+ """Home of the `EarliestStartTimeObserver` class."""
2
+
3
+ import numpy as np
4
+
5
+ from job_shop_lib.dispatching import Dispatcher
6
+ from job_shop_lib.dispatching.feature_observers import (
7
+ FeatureObserver,
8
+ FeatureType,
9
+ )
10
+ from job_shop_lib.scheduled_operation import ScheduledOperation
11
+
12
+
13
+ class EarliestStartTimeObserver(FeatureObserver):
14
+ """Observer that adds a feature indicating the earliest start time of
15
+ each operation, machine, and job in the graph."""
16
+
17
+ def __init__(
18
+ self,
19
+ dispatcher: Dispatcher,
20
+ feature_types: list[FeatureType] | FeatureType | None = None,
21
+ subscribe: bool = True,
22
+ ):
23
+
24
+ # Earliest start times initialization
25
+ # -------------------------------
26
+ squared_duration_matrix = dispatcher.instance.durations_matrix_array
27
+ self.earliest_start_times = np.hstack(
28
+ (
29
+ np.zeros((squared_duration_matrix.shape[0], 1)),
30
+ np.cumsum(squared_duration_matrix[:, :-1], axis=1),
31
+ )
32
+ )
33
+ self.earliest_start_times[np.isnan(squared_duration_matrix)] = np.nan
34
+ # -------------------------------
35
+ super().__init__(
36
+ dispatcher, feature_types, feature_size=1, subscribe=subscribe
37
+ )
38
+
39
+ def update(self, scheduled_operation: ScheduledOperation):
40
+ """Recomputes the earliest start times and calls the
41
+ `initialize_features` method.
42
+
43
+ The earliest start times is computed as the cumulative sum of the
44
+ previous unscheduled operations in the job plus the maximum of the
45
+ completion time of the last scheduled operation and the next available
46
+ time of the machine(s) the operation is assigned.
47
+
48
+ After that, we substract the current time.
49
+ """
50
+ # We compute the gap that the current scheduled operation could be
51
+ # adding to each job.
52
+ job_id = scheduled_operation.job_id
53
+ next_operation_idx = self.dispatcher.job_next_operation_index[job_id]
54
+ if next_operation_idx < len(self.dispatcher.instance.jobs[job_id]):
55
+ old_start_time = self.earliest_start_times[
56
+ job_id, next_operation_idx
57
+ ]
58
+ next_operation = self.dispatcher.instance.jobs[job_id][
59
+ next_operation_idx
60
+ ]
61
+ new_start_time = max(
62
+ scheduled_operation.end_time,
63
+ old_start_time,
64
+ self.dispatcher.earliest_start_time(next_operation),
65
+ )
66
+ gap = new_start_time - old_start_time
67
+ self.earliest_start_times[job_id, next_operation_idx:] += gap
68
+
69
+ # Now, we compute the gap that could be introduced by the new
70
+ # next_available_time of the machine.
71
+ operations_by_machine = self.dispatcher.instance.operations_by_machine
72
+ for operation in operations_by_machine[scheduled_operation.machine_id]:
73
+ if self.dispatcher.is_scheduled(operation):
74
+ continue
75
+ old_start_time = self.earliest_start_times[
76
+ operation.job_id, operation.position_in_job
77
+ ]
78
+ new_start_time = max(old_start_time, scheduled_operation.end_time)
79
+ gap = new_start_time - old_start_time
80
+ self.earliest_start_times[
81
+ operation.job_id, operation.position_in_job :
82
+ ] += gap
83
+
84
+ self.initialize_features()
85
+
86
+ def initialize_features(self):
87
+ """Initializes the features based on the current state of the
88
+ dispatcher."""
89
+ mapping = {
90
+ FeatureType.OPERATIONS: self._update_operation_features,
91
+ FeatureType.MACHINES: self._update_machine_features,
92
+ FeatureType.JOBS: self._update_job_features,
93
+ }
94
+ for feature_type in self.features:
95
+ mapping[feature_type]()
96
+
97
+ def _update_operation_features(self):
98
+ """Ravels the 2D array into a 1D array"""
99
+ current_time = self.dispatcher.current_time()
100
+ next_index = 0
101
+ for job_id, operations in enumerate(self.dispatcher.instance.jobs):
102
+ self.features[FeatureType.OPERATIONS][
103
+ next_index : next_index + len(operations), 0
104
+ ] = (
105
+ self.earliest_start_times[job_id, : len(operations)]
106
+ - current_time
107
+ )
108
+ next_index += len(operations)
109
+
110
+ def _update_machine_features(self):
111
+ """Picks the minimum start time of all operations that can be scheduled
112
+ on that machine"""
113
+ current_time = self.dispatcher.current_time()
114
+ operations_by_machine = self.dispatcher.instance.operations_by_machine
115
+ for machine_id, operations in enumerate(operations_by_machine):
116
+ min_earliest_start_time = min(
117
+ (
118
+ self.earliest_start_times[
119
+ operation.job_id, operation.position_in_job
120
+ ]
121
+ for operation in operations
122
+ if not self.dispatcher.is_scheduled(operation)
123
+ ),
124
+ default=0,
125
+ )
126
+ self.features[FeatureType.MACHINES][machine_id, 0] = (
127
+ min_earliest_start_time - current_time
128
+ )
129
+
130
+ def _update_job_features(self):
131
+ """Picks the earliest start time of the next operation in the job"""
132
+ current_time = self.dispatcher.current_time()
133
+ for job_id, next_operation_idx in enumerate(
134
+ self.dispatcher.job_next_operation_index
135
+ ):
136
+ job_length = len(self.dispatcher.instance.jobs[job_id])
137
+ if next_operation_idx == job_length:
138
+ continue
139
+ self.features[FeatureType.JOBS][job_id, 0] = (
140
+ self.earliest_start_times[job_id, next_operation_idx]
141
+ - current_time
142
+ )
143
+
144
+
145
+ if __name__ == "__main__":
146
+ squared_durations_matrix = np.array([[1, 1, 7], [5, 1, 1], [1, 3, 2]])
147
+ # Add a zeros column to the left of the matrix
148
+ cumulative_durations = np.hstack(
149
+ (
150
+ np.zeros((squared_durations_matrix.shape[0], 1)),
151
+ squared_durations_matrix[:, :-1],
152
+ )
153
+ )
154
+ # Set to nan the values that are not available
155
+ cumulative_durations[np.isnan(squared_durations_matrix)] = np.nan
156
+ print(cumulative_durations)