Skip to content

Commit 2fae158

Browse files
authored
Merge pull request #142 from jbdyn/141-periodic-constraints-interruptible-tasks
Resolve "Define time spans periodically for ResourceUnavailable"
2 parents 463bd04 + 8bdf419 commit 2fae158

File tree

5 files changed

+641
-0
lines changed

5 files changed

+641
-0
lines changed

processscheduler/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@
6363
SameWorkers,
6464
DistinctWorkers,
6565
ResourceUnavailable,
66+
ResourcePeriodicallyUnavailable,
6667
WorkLoad,
6768
ResourceTasksDistance,
6869
ResourceNonDelay,
70+
ResourceInterrupted,
71+
ResourcePeriodicallyInterrupted,
6972
)
7073
from processscheduler.indicator_constraint import IndicatorTarget, IndicatorBounds
7174
from processscheduler.resource import Worker, CumulativeWorker, SelectWorkers

processscheduler/resource_constraint.py

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from processscheduler.resource import Worker, CumulativeWorker, SelectWorkers
2626
from processscheduler.constraint import ResourceConstraint
2727
from processscheduler.util import sort_no_duplicates
28+
from processscheduler.task import VariableDurationTask
2829

2930

3031
class WorkLoad(ResourceConstraint):
@@ -197,6 +198,333 @@ def __init__(self, **data) -> None:
197198
)
198199

199200

201+
class ResourcePeriodicallyUnavailable(ResourceConstraint):
202+
"""
203+
This constraint sets the unavailability of a resource in terms of time intervals in one period.
204+
205+
Parameters:
206+
- resource: The resource for which unavailability is defined.
207+
- list_of_time_intervals: A list of time intervals *in one period* during which the resource is unavailable for any task.
208+
For example, [(0, 2), (5, 8)] represents time intervals from 0 to 2 and from 5 to 8 in one period.
209+
- period: The length of one period after which to repeat the list of time intervals.
210+
For example, setting this to 5 with [(2, 4)] gives unavailabilities at (2, 4), (7, 9), (12, 14), ...
211+
- start: The start after which repeating the list of time intervals is active (default is 0).
212+
- offset: The shift of the repeated list of time intervals (default is 0).
213+
It might be desired to set also the start parameter to the same value, as otherwise the pattern shifts in from the left or the right into the schedule.
214+
- end: The end until which repeating the list of time intervals is activate (default is None).
215+
- optional (bool, optional): Whether the constraint is optional (default is False).
216+
"""
217+
218+
resource: Union[Worker, CumulativeWorker]
219+
list_of_time_intervals: List[Tuple[int, int]]
220+
period: int
221+
start: int = 0
222+
offset: int = 0
223+
end: Union[int, None] = None
224+
225+
226+
def __init__(self, **data):
227+
"""
228+
Initialize a ResourceUnavailable constraint.
229+
230+
:param resource: The resource for which unavailability is defined.
231+
:param list_of_time_intervals: A list of time intervals *in one period* during which the resource is unavailable for any task.
232+
For example, [(0, 2), (5, 8)] represents time intervals from 0 to 2 and from 5 to 8.
233+
:param period: The length of one period after which to repeat the list of time intervals.
234+
For example, setting this to 5 with [(2, 4)] gives unavailabilities at (2, 4), (7, 9), (12, 14), ...
235+
:param start: The start after which repeating the list of time intervals is active (default is 0).
236+
:param offset: The shift of the repeated list of time intervals (default is 0).
237+
It might be desired to set also the start parameter to the same value, as otherwise the pattern shifts in from the left or the right into the schedule.
238+
:param end: The end until which repeating the list of time intervals is activate (default is None).
239+
:param optional: Whether the constraint is optional (default is False).
240+
"""
241+
super().__init__(**data)
242+
243+
if isinstance(self.resource, Worker):
244+
workers = [self.resource]
245+
elif isinstance(self.resource, CumulativeWorker):
246+
workers = self.resource.cumulative_workers
247+
248+
resource_assigned = False
249+
250+
for interval_lower_bound, interval_upper_bound in self.list_of_time_intervals:
251+
for worker in workers:
252+
for start_task_i, end_task_i in worker.get_busy_intervals():
253+
resource_assigned = True
254+
duration = end_task_i - start_task_i
255+
conds = [
256+
z3.Xor(
257+
(start_task_i - self.offset) % self.period >= interval_upper_bound,
258+
(start_task_i - self.offset) % self.period + duration <= interval_lower_bound
259+
)
260+
]
261+
262+
if self.start > 0:
263+
conds.append(end_task_i <= self.start)
264+
if self.end is not None:
265+
conds.append(start_task_i >= self.end)
266+
267+
if len(conds) > 1:
268+
self.set_z3_assertions(z3.Or(*conds))
269+
else:
270+
self.set_z3_assertions(*conds)
271+
272+
if not resource_assigned:
273+
raise AssertionError(
274+
"The resource is not assigned to any task. Please first assign the resource to one or more tasks, and then add the ResourcePeriodicallyUnavailable constraint."
275+
)
276+
277+
278+
class ResourceInterrupted(ResourceConstraint):
279+
"""
280+
This constraint sets the interrupts of a resource in terms of time intervals.
281+
282+
Parameters:
283+
- resource: The resource for which interrupts are defined.
284+
- list_of_time_intervals: A list of time intervals during which the resource is interrupting any task.
285+
For example, [(0, 2), (5, 8)] represents time intervals from 0 to 2 and from 5 to 8.
286+
- optional (bool, optional): Whether the constraint is optional (default is False).
287+
"""
288+
289+
resource: Union[Worker, CumulativeWorker]
290+
list_of_time_intervals: List[Tuple[int, int]]
291+
292+
def __init__(self, **data) -> None:
293+
"""
294+
Initialize a ResourceInterrupted constraint.
295+
296+
:param resource: The resource for which interrupts are defined.
297+
:param list_of_time_intervals: A list of time intervals during which the resource is interrupting any task.
298+
For example, [(0, 2), (5, 8)] represents time intervals from 0 to 2 and from 5 to 8.
299+
:param optional: Whether the constraint is optional (default is False).
300+
"""
301+
super().__init__(**data)
302+
303+
if isinstance(self.resource, Worker):
304+
workers = [self.resource]
305+
elif isinstance(self.resource, CumulativeWorker):
306+
workers = self.resource.cumulative_workers
307+
308+
resource_assigned = False
309+
310+
for worker in workers:
311+
conds = []
312+
for task, (start_task_i, end_task_i) in worker._busy_intervals.items():
313+
resource_assigned = True
314+
overlaps = []
315+
316+
is_interruptible = isinstance(task, VariableDurationTask)
317+
318+
for interval_lower_bound, interval_upper_bound in self.list_of_time_intervals:
319+
overlap_condition = z3.Not(z3.Xor(
320+
start_task_i >= interval_upper_bound,
321+
end_task_i <= interval_lower_bound
322+
))
323+
overlap = z3.If(
324+
overlap_condition,
325+
interval_upper_bound - interval_lower_bound,
326+
0
327+
)
328+
overlaps.append(overlap)
329+
330+
if is_interruptible:
331+
# just make sure that the task does not start or end within the time interval...
332+
# TODO: account for zero-duration?
333+
conds.extend([
334+
z3.Xor(
335+
start_task_i <= interval_lower_bound,
336+
start_task_i >= interval_upper_bound
337+
),
338+
z3.Xor(
339+
end_task_i <= interval_lower_bound,
340+
end_task_i >= interval_upper_bound
341+
)
342+
])
343+
else:
344+
# ...otherwise make sure the task does not overlap with the time interval
345+
conds.append(
346+
z3.Xor(
347+
start_task_i >= interval_upper_bound,
348+
end_task_i <= interval_lower_bound
349+
)
350+
)
351+
352+
if is_interruptible:
353+
# add assertions for task duration based on the total count of overlapped periods
354+
total_overlap = z3.Sum(*overlaps)
355+
conds.append(
356+
task._duration >= task.min_duration + total_overlap
357+
)
358+
if task.max_duration is not None:
359+
conds.append(
360+
task._duration <= task.max_duration + total_overlap
361+
)
362+
363+
# TODO: remove AND, as the solver does that anyways?
364+
self.set_z3_assertions(z3.And(*conds))
365+
366+
if not resource_assigned:
367+
raise AssertionError(
368+
"The resource is not assigned to any task. Please first assign the resource to one or more tasks, and then add the ResourceInterrupted constraint."
369+
)
370+
371+
372+
class ResourcePeriodicallyInterrupted(ResourceConstraint):
373+
"""
374+
This constraint sets the interrupts of a resource in terms of time intervals.
375+
376+
Parameters:
377+
- resource: The resource for which interrupts are defined.
378+
- list_of_time_intervals: A list of time intervals during which the resource is interrupting any task.
379+
For example, [(0, 2), (5, 8)] represents time intervals from 0 to 2 and from 5 to 8.
380+
- optional (bool, optional): Whether the constraint is optional (default is False).
381+
- period: The length of one period after which to repeat the list of time intervals.
382+
For example, setting this to 5 with [(2, 4)] gives unavailabilities at (2, 4), (7, 9), (12, 14), ...
383+
- start: The start after which repeating the list of time intervals is active (default is 0).
384+
- offset: The shift of the repeated list of time intervals (default is 0).
385+
It might be desired to set also the start parameter to the same value, as otherwise the pattern shifts in from the left or the right into the schedule.
386+
- end: The end until which repeating the list of time intervals is activate (default is None).
387+
- optional (bool, optional): Whether the constraint is optional (default is False).
388+
"""
389+
390+
resource: Union[Worker, CumulativeWorker]
391+
list_of_time_intervals: List[Tuple[int, int]]
392+
period: int
393+
start: int = 0
394+
offset: int = 0
395+
end: Union[int, None] = None
396+
397+
def __init__(self, **data) -> None:
398+
"""
399+
Initialize a ResourceInterrupted constraint.
400+
401+
:param resource: The resource for which interrupts are defined.
402+
:param list_of_time_intervals: A list of time intervals during which the resource is interrupting any task.
403+
For example, [(0, 2), (5, 8)] represents time intervals from 0 to 2 and from 5 to 8.
404+
:param optional: Whether the constraint is optional (default is False).
405+
:param period: The length of one period after which to repeat the list of time intervals.
406+
For example, setting this to 5 with [(2, 4)] gives unavailabilities at (2, 4), (7, 9), (12, 14), ...
407+
:param start: The start after which repeating the list of time intervals is active (default is 0).
408+
:param offset: The shift of the repeated list of time intervals (default is 0).
409+
It might be desired to set also the start parameter to the same value, as otherwise the pattern shifts in from the left or the right into the schedule.
410+
:param end: The end until which repeating the list of time intervals is activate (default is None).
411+
:param optional: Whether the constraint is optional (default is False).
412+
"""
413+
super().__init__(**data)
414+
415+
if isinstance(self.resource, Worker):
416+
workers = [self.resource]
417+
elif isinstance(self.resource, CumulativeWorker):
418+
workers = self.resource.cumulative_workers
419+
420+
resource_assigned = False
421+
422+
for worker in workers:
423+
conds = []
424+
for task, (start_task_i, end_task_i) in worker._busy_intervals.items():
425+
resource_assigned = True
426+
overlaps = []
427+
428+
# check if the task allows variable duration
429+
is_interruptible = isinstance(task, VariableDurationTask)
430+
431+
duration = end_task_i - start_task_i
432+
folded_start_task_i = (start_task_i - self.offset) % self.period
433+
folded_end_task_i = (end_task_i - self.offset) % self.period
434+
435+
for interval_lower_bound, interval_upper_bound in self.list_of_time_intervals:
436+
# intervals need to be defined in one period
437+
if interval_upper_bound > self.period:
438+
raise AssertionError(f"interval ({interval_lower_bound}, {interval_upper_bound}) exceeds period {self.period}")
439+
440+
# if true, the folded task overlaps with the time interval in the first period
441+
crossing_condition = z3.Not(z3.Xor(
442+
# folded task is completely before the first time interval
443+
z3.And(
444+
folded_start_task_i <= interval_lower_bound,
445+
folded_start_task_i + duration % self.period <= interval_lower_bound,
446+
),
447+
# folded task is completely between the first and second time interval
448+
z3.And(
449+
folded_start_task_i >= interval_upper_bound,
450+
folded_start_task_i + duration % self.period <= interval_lower_bound + self.period,
451+
)
452+
))
453+
454+
# if true, the task overlaps with at least one time interval
455+
overlap_condition = z3.Or(
456+
crossing_condition,
457+
# task does not fit between two intervals
458+
duration > interval_lower_bound + self.period - interval_upper_bound
459+
)
460+
461+
# adjust the number of crossed time intervals
462+
crossings = z3.If(
463+
crossing_condition,
464+
duration / self.period + 1,
465+
duration / self.period
466+
)
467+
# calculate the total overlap for this particular time interval
468+
overlap = z3.If(
469+
overlap_condition,
470+
(interval_upper_bound - interval_lower_bound) * crossings,
471+
0
472+
)
473+
overlaps.append(overlap)
474+
475+
if is_interruptible:
476+
# just make sure that the task does not start or end within one of the time intervals...
477+
# TODO: account for zero-duration?
478+
conds.extend([
479+
z3.Xor(
480+
folded_start_task_i <= interval_lower_bound,
481+
folded_start_task_i >= interval_upper_bound
482+
),
483+
z3.Xor(
484+
folded_end_task_i <= interval_lower_bound,
485+
folded_end_task_i >= interval_upper_bound
486+
)
487+
])
488+
else:
489+
# ...otherwise make sure the task does not overlap with any of time intervals
490+
conds.append(
491+
z3.Xor(
492+
folded_start_task_i >= interval_upper_bound,
493+
folded_start_task_i + duration <= interval_lower_bound
494+
)
495+
)
496+
497+
if is_interruptible:
498+
# add assertions for task duration based on the total count of overlapped periods
499+
total_overlap = z3.Sum(*overlaps)
500+
conds.append(
501+
task._duration >= task.min_duration + total_overlap
502+
)
503+
if task.max_duration is not None:
504+
conds.append(
505+
task._duration <= task.max_duration + total_overlap
506+
)
507+
508+
# TODO: add AND only of mask is set?
509+
core = z3.And(*conds)
510+
511+
mask = [core]
512+
if self.start > 0:
513+
mask.append(end_task_i <= self.start)
514+
if self.end is not None:
515+
mask.append(start_task_i >= self.end)
516+
517+
if len(mask) > 1:
518+
self.set_z3_assertions(z3.Or(*mask))
519+
else:
520+
self.set_z3_assertions(*mask)
521+
522+
if not resource_assigned:
523+
raise AssertionError(
524+
"The resource is not assigned to any task. Please first assign the resource to one or more tasks, and then add the ResourcePeriodicallyInterrupted constraint."
525+
)
526+
527+
200528
class ResourceNonDelay(ResourceConstraint):
201529
"""All tasks processed by the resource are contiguous, there's no idle while an operation
202530
if waiting for processing"""

0 commit comments

Comments
 (0)