pycsp3-scheduling 0.2.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.
- pycsp3_scheduling/__init__.py +220 -0
- pycsp3_scheduling/constraints/__init__.py +87 -0
- pycsp3_scheduling/constraints/_pycsp3.py +701 -0
- pycsp3_scheduling/constraints/cumulative.py +227 -0
- pycsp3_scheduling/constraints/grouping.py +382 -0
- pycsp3_scheduling/constraints/precedence.py +376 -0
- pycsp3_scheduling/constraints/sequence.py +814 -0
- pycsp3_scheduling/expressions/__init__.py +80 -0
- pycsp3_scheduling/expressions/element.py +313 -0
- pycsp3_scheduling/expressions/interval_expr.py +495 -0
- pycsp3_scheduling/expressions/sequence_expr.py +865 -0
- pycsp3_scheduling/functions/__init__.py +111 -0
- pycsp3_scheduling/functions/cumul_functions.py +891 -0
- pycsp3_scheduling/functions/state_functions.py +494 -0
- pycsp3_scheduling/interop.py +356 -0
- pycsp3_scheduling/output/__init__.py +13 -0
- pycsp3_scheduling/solvers/__init__.py +14 -0
- pycsp3_scheduling/solvers/adapters/__init__.py +7 -0
- pycsp3_scheduling/variables/__init__.py +45 -0
- pycsp3_scheduling/variables/interval.py +450 -0
- pycsp3_scheduling/variables/sequence.py +244 -0
- pycsp3_scheduling/visu.py +1315 -0
- pycsp3_scheduling-0.2.1.dist-info/METADATA +234 -0
- pycsp3_scheduling-0.2.1.dist-info/RECORD +26 -0
- pycsp3_scheduling-0.2.1.dist-info/WHEEL +4 -0
- pycsp3_scheduling-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cumulative functions for resource modeling in scheduling.
|
|
3
|
+
|
|
4
|
+
A cumulative function represents the usage of a resource over time.
|
|
5
|
+
It is built by summing elementary cumulative expressions:
|
|
6
|
+
- pulse(interval, height): Rectangular usage during interval
|
|
7
|
+
- step_at(time, height): Permanent step change at a time point
|
|
8
|
+
- step_at_start(interval, height): Step change at interval start
|
|
9
|
+
- step_at_end(interval, height): Step change at interval end
|
|
10
|
+
|
|
11
|
+
Cumulative functions can be constrained:
|
|
12
|
+
- cumul <= max_capacity: Never exceed capacity
|
|
13
|
+
- cumul >= min_level: Always maintain minimum level
|
|
14
|
+
- always_in(cumul, interval, min, max): Bound within time range
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> tasks = [IntervalVar(size=5, name=f"t{i}") for i in range(3)]
|
|
18
|
+
>>> resource_usage = sum(pulse(t, height=2) for t in tasks)
|
|
19
|
+
>>> satisfy(resource_usage <= 4) # Capacity constraint
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum, auto
|
|
26
|
+
from typing import TYPE_CHECKING, Sequence, Union
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CumulExprType(Enum):
|
|
33
|
+
"""Types of cumulative expressions."""
|
|
34
|
+
|
|
35
|
+
PULSE = auto() # Rectangular pulse during interval
|
|
36
|
+
STEP_AT = auto() # Step at fixed time
|
|
37
|
+
STEP_AT_START = auto() # Step at interval start
|
|
38
|
+
STEP_AT_END = auto() # Step at interval end
|
|
39
|
+
SUM = auto() # Sum of cumul expressions
|
|
40
|
+
NEG = auto() # Negation
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CumulExpr:
|
|
45
|
+
"""
|
|
46
|
+
Elementary cumulative expression.
|
|
47
|
+
|
|
48
|
+
Represents a contribution to a cumulative function from a single
|
|
49
|
+
interval or time point.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
expr_type: Type of cumulative expression.
|
|
53
|
+
interval: Associated interval (for pulse, step_at_start, step_at_end).
|
|
54
|
+
time: Fixed time point (for step_at).
|
|
55
|
+
height: Fixed height value.
|
|
56
|
+
height_min: Minimum height (for variable height).
|
|
57
|
+
height_max: Maximum height (for variable height).
|
|
58
|
+
operands: Child expressions (for SUM).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
expr_type: CumulExprType
|
|
62
|
+
interval: IntervalVar | None = None
|
|
63
|
+
time: int | None = None
|
|
64
|
+
height: int | None = None
|
|
65
|
+
height_min: int | None = None
|
|
66
|
+
height_max: int | None = None
|
|
67
|
+
operands: list[CumulExpr] = field(default_factory=list)
|
|
68
|
+
_id: int = field(default=-1, repr=False)
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
"""Assign unique ID."""
|
|
72
|
+
if self._id == -1:
|
|
73
|
+
self._id = CumulExpr._get_next_id()
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _get_next_id() -> int:
|
|
77
|
+
"""Get next unique ID."""
|
|
78
|
+
current = getattr(CumulExpr, "_id_counter", 0)
|
|
79
|
+
CumulExpr._id_counter = current + 1
|
|
80
|
+
return current
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_variable_height(self) -> bool:
|
|
84
|
+
"""Whether this expression has variable height."""
|
|
85
|
+
return self.height_min is not None and self.height_min != self.height_max
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def fixed_height(self) -> int | None:
|
|
89
|
+
"""Return fixed height if constant, else None."""
|
|
90
|
+
if self.height is not None:
|
|
91
|
+
return self.height
|
|
92
|
+
if self.height_min is not None and self.height_min == self.height_max:
|
|
93
|
+
return self.height_min
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def __add__(self, other: Union[CumulExpr, CumulFunction, int]) -> CumulFunction:
|
|
97
|
+
"""Add cumulative expressions."""
|
|
98
|
+
if isinstance(other, int):
|
|
99
|
+
if other == 0:
|
|
100
|
+
return CumulFunction(expressions=[self])
|
|
101
|
+
raise TypeError("Cannot add non-zero integer to CumulExpr")
|
|
102
|
+
if isinstance(other, CumulExpr):
|
|
103
|
+
return CumulFunction(expressions=[self, other])
|
|
104
|
+
if isinstance(other, CumulFunction):
|
|
105
|
+
return CumulFunction(expressions=[self] + other.expressions)
|
|
106
|
+
return NotImplemented
|
|
107
|
+
|
|
108
|
+
def __radd__(self, other: Union[CumulExpr, CumulFunction, int]) -> CumulFunction:
|
|
109
|
+
"""Right addition (supports sum() starting with 0)."""
|
|
110
|
+
if isinstance(other, int) and other == 0:
|
|
111
|
+
return CumulFunction(expressions=[self])
|
|
112
|
+
return self.__add__(other)
|
|
113
|
+
|
|
114
|
+
def __neg__(self) -> CumulExpr:
|
|
115
|
+
"""Negate the cumulative expression."""
|
|
116
|
+
return CumulExpr(
|
|
117
|
+
expr_type=CumulExprType.NEG,
|
|
118
|
+
operands=[self],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def __hash__(self) -> int:
|
|
122
|
+
"""Hash based on unique ID."""
|
|
123
|
+
return hash(self._id)
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
"""String representation."""
|
|
127
|
+
if self.expr_type == CumulExprType.PULSE:
|
|
128
|
+
name = self.interval.name if self.interval else "?"
|
|
129
|
+
h = self.height if self.height is not None else f"[{self.height_min},{self.height_max}]"
|
|
130
|
+
return f"pulse({name}, {h})"
|
|
131
|
+
elif self.expr_type == CumulExprType.STEP_AT:
|
|
132
|
+
return f"step_at({self.time}, {self.height})"
|
|
133
|
+
elif self.expr_type == CumulExprType.STEP_AT_START:
|
|
134
|
+
name = self.interval.name if self.interval else "?"
|
|
135
|
+
h = self.height if self.height is not None else f"[{self.height_min},{self.height_max}]"
|
|
136
|
+
return f"step_at_start({name}, {h})"
|
|
137
|
+
elif self.expr_type == CumulExprType.STEP_AT_END:
|
|
138
|
+
name = self.interval.name if self.interval else "?"
|
|
139
|
+
h = self.height if self.height is not None else f"[{self.height_min},{self.height_max}]"
|
|
140
|
+
return f"step_at_end({name}, {h})"
|
|
141
|
+
elif self.expr_type == CumulExprType.NEG:
|
|
142
|
+
return f"-({self.operands[0]})"
|
|
143
|
+
elif self.expr_type == CumulExprType.SUM:
|
|
144
|
+
return " + ".join(str(op) for op in self.operands)
|
|
145
|
+
return f"CumulExpr({self.expr_type})"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class CumulFunction:
|
|
150
|
+
"""
|
|
151
|
+
Cumulative function representing resource usage over time.
|
|
152
|
+
|
|
153
|
+
A cumulative function is the sum of elementary cumulative expressions
|
|
154
|
+
(pulse, step_at_start, step_at_end, step_at). It can be constrained
|
|
155
|
+
using comparison operators.
|
|
156
|
+
|
|
157
|
+
Attributes:
|
|
158
|
+
expressions: List of elementary cumulative expressions.
|
|
159
|
+
name: Optional name for the function.
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
>>> tasks = [IntervalVar(size=5, name=f"t{i}") for i in range(3)]
|
|
163
|
+
>>> demands = [2, 3, 1]
|
|
164
|
+
>>> usage = CumulFunction()
|
|
165
|
+
>>> for task, d in zip(tasks, demands):
|
|
166
|
+
... usage += pulse(task, d)
|
|
167
|
+
>>> satisfy(usage <= 5) # Capacity 5
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
expressions: list[CumulExpr] = field(default_factory=list)
|
|
171
|
+
name: str | None = None
|
|
172
|
+
_id: int = field(default=-1, repr=False)
|
|
173
|
+
|
|
174
|
+
def __post_init__(self) -> None:
|
|
175
|
+
"""Assign unique ID."""
|
|
176
|
+
if self._id == -1:
|
|
177
|
+
self._id = CumulFunction._get_next_id()
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _get_next_id() -> int:
|
|
181
|
+
"""Get next unique ID."""
|
|
182
|
+
current = getattr(CumulFunction, "_id_counter", 0)
|
|
183
|
+
CumulFunction._id_counter = current + 1
|
|
184
|
+
return current
|
|
185
|
+
|
|
186
|
+
def __add__(self, other: Union[CumulExpr, CumulFunction, int]) -> CumulFunction:
|
|
187
|
+
"""Add cumulative expression or function."""
|
|
188
|
+
if isinstance(other, int):
|
|
189
|
+
if other == 0:
|
|
190
|
+
return self
|
|
191
|
+
raise TypeError("Cannot add non-zero integer to CumulFunction")
|
|
192
|
+
if isinstance(other, CumulExpr):
|
|
193
|
+
return CumulFunction(
|
|
194
|
+
expressions=self.expressions + [other],
|
|
195
|
+
name=self.name,
|
|
196
|
+
)
|
|
197
|
+
if isinstance(other, CumulFunction):
|
|
198
|
+
return CumulFunction(
|
|
199
|
+
expressions=self.expressions + other.expressions,
|
|
200
|
+
name=self.name,
|
|
201
|
+
)
|
|
202
|
+
return NotImplemented
|
|
203
|
+
|
|
204
|
+
def __radd__(self, other: Union[CumulExpr, CumulFunction, int]) -> CumulFunction:
|
|
205
|
+
"""Right addition (supports sum())."""
|
|
206
|
+
if isinstance(other, int) and other == 0:
|
|
207
|
+
return self
|
|
208
|
+
return self.__add__(other)
|
|
209
|
+
|
|
210
|
+
def __iadd__(self, other: Union[CumulExpr, CumulFunction]) -> CumulFunction:
|
|
211
|
+
"""In-place addition."""
|
|
212
|
+
if isinstance(other, CumulExpr):
|
|
213
|
+
self.expressions.append(other)
|
|
214
|
+
return self
|
|
215
|
+
if isinstance(other, CumulFunction):
|
|
216
|
+
self.expressions.extend(other.expressions)
|
|
217
|
+
return self
|
|
218
|
+
return NotImplemented
|
|
219
|
+
|
|
220
|
+
def __neg__(self) -> CumulFunction:
|
|
221
|
+
"""Negate all expressions."""
|
|
222
|
+
return CumulFunction(
|
|
223
|
+
expressions=[-expr for expr in self.expressions],
|
|
224
|
+
name=self.name,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Comparison operators for constraints
|
|
228
|
+
def __le__(self, other: int):
|
|
229
|
+
"""cumul <= capacity constraint. Returns pycsp3-compatible constraint."""
|
|
230
|
+
if not isinstance(other, int):
|
|
231
|
+
raise TypeError(f"CumulFunction can only be compared with int, got {type(other)}")
|
|
232
|
+
return self._build_capacity_constraint(other)
|
|
233
|
+
|
|
234
|
+
def __ge__(self, other: int) -> CumulConstraint:
|
|
235
|
+
"""cumul >= level constraint."""
|
|
236
|
+
if not isinstance(other, int):
|
|
237
|
+
raise TypeError(f"CumulFunction can only be compared with int, got {type(other)}")
|
|
238
|
+
return CumulConstraint(
|
|
239
|
+
cumul=self,
|
|
240
|
+
constraint_type=CumulConstraintType.GE,
|
|
241
|
+
bound=other,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def __lt__(self, other: int) -> CumulConstraint:
|
|
245
|
+
"""cumul < bound constraint."""
|
|
246
|
+
if not isinstance(other, int):
|
|
247
|
+
raise TypeError(f"CumulFunction can only be compared with int, got {type(other)}")
|
|
248
|
+
return CumulConstraint(
|
|
249
|
+
cumul=self,
|
|
250
|
+
constraint_type=CumulConstraintType.LT,
|
|
251
|
+
bound=other,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def __gt__(self, other: int) -> CumulConstraint:
|
|
255
|
+
"""cumul > bound constraint."""
|
|
256
|
+
if not isinstance(other, int):
|
|
257
|
+
raise TypeError(f"CumulFunction can only be compared with int, got {type(other)}")
|
|
258
|
+
return CumulConstraint(
|
|
259
|
+
cumul=self,
|
|
260
|
+
constraint_type=CumulConstraintType.GT,
|
|
261
|
+
bound=other,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _build_capacity_constraint(self, capacity: int):
|
|
265
|
+
"""
|
|
266
|
+
Build pycsp3 Cumulative constraint for simple pulse-based functions.
|
|
267
|
+
|
|
268
|
+
For cumulative functions that are sums of pulses with fixed heights,
|
|
269
|
+
this returns a pycsp3 Cumulative constraint directly.
|
|
270
|
+
"""
|
|
271
|
+
from pycsp3 import Cumulative
|
|
272
|
+
from pycsp3_scheduling.constraints._pycsp3 import length_value, start_var
|
|
273
|
+
|
|
274
|
+
# Check if all expressions are simple pulses
|
|
275
|
+
intervals = []
|
|
276
|
+
heights = []
|
|
277
|
+
|
|
278
|
+
for expr in self.expressions:
|
|
279
|
+
if expr.expr_type == CumulExprType.NEG:
|
|
280
|
+
# Negated pulse
|
|
281
|
+
if expr.operands and expr.operands[0].expr_type == CumulExprType.PULSE:
|
|
282
|
+
inner = expr.operands[0]
|
|
283
|
+
if inner.is_variable_height:
|
|
284
|
+
# Variable height not supported by simple Cumulative
|
|
285
|
+
return CumulConstraint(
|
|
286
|
+
cumul=self,
|
|
287
|
+
constraint_type=CumulConstraintType.LE,
|
|
288
|
+
bound=capacity,
|
|
289
|
+
)
|
|
290
|
+
intervals.append(inner.interval)
|
|
291
|
+
heights.append(-(inner.height or inner.height_min))
|
|
292
|
+
else:
|
|
293
|
+
return CumulConstraint(
|
|
294
|
+
cumul=self,
|
|
295
|
+
constraint_type=CumulConstraintType.LE,
|
|
296
|
+
bound=capacity,
|
|
297
|
+
)
|
|
298
|
+
elif expr.expr_type == CumulExprType.PULSE:
|
|
299
|
+
if expr.is_variable_height:
|
|
300
|
+
return CumulConstraint(
|
|
301
|
+
cumul=self,
|
|
302
|
+
constraint_type=CumulConstraintType.LE,
|
|
303
|
+
bound=capacity,
|
|
304
|
+
)
|
|
305
|
+
intervals.append(expr.interval)
|
|
306
|
+
heights.append(expr.height or expr.height_min)
|
|
307
|
+
else:
|
|
308
|
+
# Non-pulse expression, fall back to CumulConstraint
|
|
309
|
+
return CumulConstraint(
|
|
310
|
+
cumul=self,
|
|
311
|
+
constraint_type=CumulConstraintType.LE,
|
|
312
|
+
bound=capacity,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Filter out negative heights (not supported by standard Cumulative)
|
|
316
|
+
if any(h < 0 for h in heights):
|
|
317
|
+
return CumulConstraint(
|
|
318
|
+
cumul=self,
|
|
319
|
+
constraint_type=CumulConstraintType.LE,
|
|
320
|
+
bound=capacity,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Filter out zero heights
|
|
324
|
+
filtered = [(iv, h) for iv, h in zip(intervals, heights) if h > 0]
|
|
325
|
+
if not filtered:
|
|
326
|
+
# No actual resource usage, constraint is trivially satisfied
|
|
327
|
+
return []
|
|
328
|
+
|
|
329
|
+
intervals, heights = zip(*filtered)
|
|
330
|
+
|
|
331
|
+
# Build pycsp3 Cumulative constraint
|
|
332
|
+
origins = [start_var(iv) for iv in intervals]
|
|
333
|
+
lengths = [length_value(iv) for iv in intervals]
|
|
334
|
+
|
|
335
|
+
return Cumulative(origins=origins, lengths=lengths, heights=list(heights)) <= capacity
|
|
336
|
+
|
|
337
|
+
def __hash__(self) -> int:
|
|
338
|
+
"""Hash based on unique ID."""
|
|
339
|
+
return hash(self._id)
|
|
340
|
+
|
|
341
|
+
def __repr__(self) -> str:
|
|
342
|
+
"""String representation."""
|
|
343
|
+
if self.name:
|
|
344
|
+
return f"CumulFunction({self.name})"
|
|
345
|
+
if not self.expressions:
|
|
346
|
+
return "CumulFunction()"
|
|
347
|
+
return f"CumulFunction({' + '.join(str(e) for e in self.expressions)})"
|
|
348
|
+
|
|
349
|
+
def get_intervals(self) -> list[IntervalVar]:
|
|
350
|
+
"""Get all intervals referenced by this cumulative function."""
|
|
351
|
+
intervals = []
|
|
352
|
+
for expr in self.expressions:
|
|
353
|
+
if expr.interval is not None:
|
|
354
|
+
intervals.append(expr.interval)
|
|
355
|
+
for op in expr.operands:
|
|
356
|
+
if op.interval is not None:
|
|
357
|
+
intervals.append(op.interval)
|
|
358
|
+
return intervals
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class CumulConstraintType(Enum):
|
|
362
|
+
"""Types of cumulative constraints."""
|
|
363
|
+
|
|
364
|
+
LE = auto() # <=
|
|
365
|
+
GE = auto() # >=
|
|
366
|
+
LT = auto() # <
|
|
367
|
+
GT = auto() # >
|
|
368
|
+
RANGE = auto() # min <= cumul <= max
|
|
369
|
+
ALWAYS_IN = auto() # always_in over time range
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass
|
|
373
|
+
class CumulConstraint:
|
|
374
|
+
"""
|
|
375
|
+
Constraint on a cumulative function.
|
|
376
|
+
|
|
377
|
+
Attributes:
|
|
378
|
+
cumul: The cumulative function being constrained.
|
|
379
|
+
constraint_type: Type of constraint (LE, GE, RANGE, etc.).
|
|
380
|
+
bound: Upper or lower bound (for LE, GE, LT, GT).
|
|
381
|
+
min_bound: Minimum bound (for RANGE, ALWAYS_IN).
|
|
382
|
+
max_bound: Maximum bound (for RANGE, ALWAYS_IN).
|
|
383
|
+
interval: Time interval for ALWAYS_IN constraint.
|
|
384
|
+
start_time: Start time for fixed time range.
|
|
385
|
+
end_time: End time for fixed time range.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
cumul: CumulFunction
|
|
389
|
+
constraint_type: CumulConstraintType
|
|
390
|
+
bound: int | None = None
|
|
391
|
+
min_bound: int | None = None
|
|
392
|
+
max_bound: int | None = None
|
|
393
|
+
interval: IntervalVar | None = None
|
|
394
|
+
start_time: int | None = None
|
|
395
|
+
end_time: int | None = None
|
|
396
|
+
|
|
397
|
+
def __repr__(self) -> str:
|
|
398
|
+
"""String representation."""
|
|
399
|
+
if self.constraint_type == CumulConstraintType.LE:
|
|
400
|
+
return f"{self.cumul} <= {self.bound}"
|
|
401
|
+
elif self.constraint_type == CumulConstraintType.GE:
|
|
402
|
+
return f"{self.cumul} >= {self.bound}"
|
|
403
|
+
elif self.constraint_type == CumulConstraintType.LT:
|
|
404
|
+
return f"{self.cumul} < {self.bound}"
|
|
405
|
+
elif self.constraint_type == CumulConstraintType.GT:
|
|
406
|
+
return f"{self.cumul} > {self.bound}"
|
|
407
|
+
elif self.constraint_type == CumulConstraintType.RANGE:
|
|
408
|
+
return f"{self.min_bound} <= {self.cumul} <= {self.max_bound}"
|
|
409
|
+
elif self.constraint_type == CumulConstraintType.ALWAYS_IN:
|
|
410
|
+
if self.interval:
|
|
411
|
+
return f"always_in({self.cumul}, {self.interval.name}, {self.min_bound}, {self.max_bound})"
|
|
412
|
+
else:
|
|
413
|
+
return f"always_in({self.cumul}, ({self.start_time}, {self.end_time}), {self.min_bound}, {self.max_bound})"
|
|
414
|
+
return f"CumulConstraint({self.constraint_type})"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# =============================================================================
|
|
418
|
+
# Elementary Cumulative Functions
|
|
419
|
+
# =============================================================================
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def pulse(
|
|
423
|
+
interval: IntervalVar,
|
|
424
|
+
height: int | None = None,
|
|
425
|
+
height_min: int | None = None,
|
|
426
|
+
height_max: int | None = None,
|
|
427
|
+
) -> CumulExpr:
|
|
428
|
+
"""
|
|
429
|
+
Create a pulse contribution to a cumulative function.
|
|
430
|
+
|
|
431
|
+
A pulse represents resource usage during the execution of an interval.
|
|
432
|
+
The resource is consumed at the specified height from the start to
|
|
433
|
+
the end of the interval.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
interval: The interval variable.
|
|
437
|
+
height: Fixed height (resource consumption).
|
|
438
|
+
height_min: Minimum height for variable consumption.
|
|
439
|
+
height_max: Maximum height for variable consumption.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
A CumulExpr representing the pulse.
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
TypeError: If interval is not an IntervalVar.
|
|
446
|
+
ValueError: If height specification is invalid.
|
|
447
|
+
|
|
448
|
+
Example:
|
|
449
|
+
>>> task = IntervalVar(size=10, name="task")
|
|
450
|
+
>>> p = pulse(task, height=3) # Fixed height 3
|
|
451
|
+
>>> p = pulse(task, height_min=1, height_max=5) # Variable height
|
|
452
|
+
"""
|
|
453
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
454
|
+
|
|
455
|
+
if not isinstance(interval, IntervalVar):
|
|
456
|
+
raise TypeError(f"interval must be an IntervalVar, got {type(interval).__name__}")
|
|
457
|
+
|
|
458
|
+
# Validate height specification
|
|
459
|
+
if height is not None:
|
|
460
|
+
if height_min is not None or height_max is not None:
|
|
461
|
+
raise ValueError("Cannot specify both height and height_min/height_max")
|
|
462
|
+
if not isinstance(height, int):
|
|
463
|
+
raise TypeError(f"height must be an int, got {type(height).__name__}")
|
|
464
|
+
return CumulExpr(
|
|
465
|
+
expr_type=CumulExprType.PULSE,
|
|
466
|
+
interval=interval,
|
|
467
|
+
height=height,
|
|
468
|
+
)
|
|
469
|
+
elif height_min is not None and height_max is not None:
|
|
470
|
+
if not isinstance(height_min, int) or not isinstance(height_max, int):
|
|
471
|
+
raise TypeError("height_min and height_max must be integers")
|
|
472
|
+
if height_min > height_max:
|
|
473
|
+
raise ValueError(f"height_min ({height_min}) cannot exceed height_max ({height_max})")
|
|
474
|
+
return CumulExpr(
|
|
475
|
+
expr_type=CumulExprType.PULSE,
|
|
476
|
+
interval=interval,
|
|
477
|
+
height_min=height_min,
|
|
478
|
+
height_max=height_max,
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
raise ValueError("Must specify either height or both height_min and height_max")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def step_at(time: int, height: int) -> CumulExpr:
|
|
485
|
+
"""
|
|
486
|
+
Create a step contribution at a fixed time point.
|
|
487
|
+
|
|
488
|
+
The cumulative function increases (or decreases if negative) by the
|
|
489
|
+
specified height at the given time point and stays at that level.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
time: The time point for the step.
|
|
493
|
+
height: The step height (positive for increase, negative for decrease).
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
A CumulExpr representing the step.
|
|
497
|
+
|
|
498
|
+
Raises:
|
|
499
|
+
TypeError: If time or height are not integers.
|
|
500
|
+
|
|
501
|
+
Example:
|
|
502
|
+
>>> s = step_at(10, 5) # Increase by 5 at time 10
|
|
503
|
+
>>> s = step_at(20, -3) # Decrease by 3 at time 20
|
|
504
|
+
"""
|
|
505
|
+
if not isinstance(time, int):
|
|
506
|
+
raise TypeError(f"time must be an int, got {type(time).__name__}")
|
|
507
|
+
if not isinstance(height, int):
|
|
508
|
+
raise TypeError(f"height must be an int, got {type(height).__name__}")
|
|
509
|
+
|
|
510
|
+
return CumulExpr(
|
|
511
|
+
expr_type=CumulExprType.STEP_AT,
|
|
512
|
+
time=time,
|
|
513
|
+
height=height,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def step_at_start(
|
|
518
|
+
interval: IntervalVar,
|
|
519
|
+
height: int | None = None,
|
|
520
|
+
height_min: int | None = None,
|
|
521
|
+
height_max: int | None = None,
|
|
522
|
+
) -> CumulExpr:
|
|
523
|
+
"""
|
|
524
|
+
Create a step contribution at the start of an interval.
|
|
525
|
+
|
|
526
|
+
The cumulative function increases (or decreases) by the specified
|
|
527
|
+
height at the start of the interval. The change is permanent.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
interval: The interval variable.
|
|
531
|
+
height: Fixed step height.
|
|
532
|
+
height_min: Minimum height for variable step.
|
|
533
|
+
height_max: Maximum height for variable step.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
A CumulExpr representing the step at start.
|
|
537
|
+
|
|
538
|
+
Raises:
|
|
539
|
+
TypeError: If interval is not an IntervalVar.
|
|
540
|
+
ValueError: If height specification is invalid.
|
|
541
|
+
|
|
542
|
+
Example:
|
|
543
|
+
>>> task = IntervalVar(size=10, name="task")
|
|
544
|
+
>>> s = step_at_start(task, height=2) # Increase by 2 at start
|
|
545
|
+
"""
|
|
546
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
547
|
+
|
|
548
|
+
if not isinstance(interval, IntervalVar):
|
|
549
|
+
raise TypeError(f"interval must be an IntervalVar, got {type(interval).__name__}")
|
|
550
|
+
|
|
551
|
+
if height is not None:
|
|
552
|
+
if height_min is not None or height_max is not None:
|
|
553
|
+
raise ValueError("Cannot specify both height and height_min/height_max")
|
|
554
|
+
if not isinstance(height, int):
|
|
555
|
+
raise TypeError(f"height must be an int, got {type(height).__name__}")
|
|
556
|
+
return CumulExpr(
|
|
557
|
+
expr_type=CumulExprType.STEP_AT_START,
|
|
558
|
+
interval=interval,
|
|
559
|
+
height=height,
|
|
560
|
+
)
|
|
561
|
+
elif height_min is not None and height_max is not None:
|
|
562
|
+
if not isinstance(height_min, int) or not isinstance(height_max, int):
|
|
563
|
+
raise TypeError("height_min and height_max must be integers")
|
|
564
|
+
if height_min > height_max:
|
|
565
|
+
raise ValueError(f"height_min ({height_min}) cannot exceed height_max ({height_max})")
|
|
566
|
+
return CumulExpr(
|
|
567
|
+
expr_type=CumulExprType.STEP_AT_START,
|
|
568
|
+
interval=interval,
|
|
569
|
+
height_min=height_min,
|
|
570
|
+
height_max=height_max,
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
raise ValueError("Must specify either height or both height_min and height_max")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def step_at_end(
|
|
577
|
+
interval: IntervalVar,
|
|
578
|
+
height: int | None = None,
|
|
579
|
+
height_min: int | None = None,
|
|
580
|
+
height_max: int | None = None,
|
|
581
|
+
) -> CumulExpr:
|
|
582
|
+
"""
|
|
583
|
+
Create a step contribution at the end of an interval.
|
|
584
|
+
|
|
585
|
+
The cumulative function increases (or decreases) by the specified
|
|
586
|
+
height at the end of the interval. The change is permanent.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
interval: The interval variable.
|
|
590
|
+
height: Fixed step height.
|
|
591
|
+
height_min: Minimum height for variable step.
|
|
592
|
+
height_max: Maximum height for variable step.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
A CumulExpr representing the step at end.
|
|
596
|
+
|
|
597
|
+
Raises:
|
|
598
|
+
TypeError: If interval is not an IntervalVar.
|
|
599
|
+
ValueError: If height specification is invalid.
|
|
600
|
+
|
|
601
|
+
Example:
|
|
602
|
+
>>> task = IntervalVar(size=10, name="task")
|
|
603
|
+
>>> # Model reservoir: +2 at start (acquire), -2 at end (release)
|
|
604
|
+
>>> usage = step_at_start(task, 2) + step_at_end(task, -2)
|
|
605
|
+
"""
|
|
606
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
607
|
+
|
|
608
|
+
if not isinstance(interval, IntervalVar):
|
|
609
|
+
raise TypeError(f"interval must be an IntervalVar, got {type(interval).__name__}")
|
|
610
|
+
|
|
611
|
+
if height is not None:
|
|
612
|
+
if height_min is not None or height_max is not None:
|
|
613
|
+
raise ValueError("Cannot specify both height and height_min/height_max")
|
|
614
|
+
if not isinstance(height, int):
|
|
615
|
+
raise TypeError(f"height must be an int, got {type(height).__name__}")
|
|
616
|
+
return CumulExpr(
|
|
617
|
+
expr_type=CumulExprType.STEP_AT_END,
|
|
618
|
+
interval=interval,
|
|
619
|
+
height=height,
|
|
620
|
+
)
|
|
621
|
+
elif height_min is not None and height_max is not None:
|
|
622
|
+
if not isinstance(height_min, int) or not isinstance(height_max, int):
|
|
623
|
+
raise TypeError("height_min and height_max must be integers")
|
|
624
|
+
if height_min > height_max:
|
|
625
|
+
raise ValueError(f"height_min ({height_min}) cannot exceed height_max ({height_max})")
|
|
626
|
+
return CumulExpr(
|
|
627
|
+
expr_type=CumulExprType.STEP_AT_END,
|
|
628
|
+
interval=interval,
|
|
629
|
+
height_min=height_min,
|
|
630
|
+
height_max=height_max,
|
|
631
|
+
)
|
|
632
|
+
else:
|
|
633
|
+
raise ValueError("Must specify either height or both height_min and height_max")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# =============================================================================
|
|
637
|
+
# Cumulative Constraint Functions
|
|
638
|
+
# =============================================================================
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def cumul_range(cumul: CumulFunction, min_val: int, max_val: int):
|
|
642
|
+
"""
|
|
643
|
+
Constrain a cumulative function to stay within a range.
|
|
644
|
+
|
|
645
|
+
The cumulative function must satisfy min_val <= cumul <= max_val
|
|
646
|
+
at all time points.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
cumul: The cumulative function.
|
|
650
|
+
min_val: Minimum allowed value.
|
|
651
|
+
max_val: Maximum allowed value.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
A pycsp3-compatible constraint when possible (for simple pulse-based
|
|
655
|
+
cumulative functions with min_val=0), otherwise a CumulConstraint.
|
|
656
|
+
|
|
657
|
+
Raises:
|
|
658
|
+
TypeError: If cumul is not a CumulFunction.
|
|
659
|
+
ValueError: If min_val > max_val.
|
|
660
|
+
|
|
661
|
+
Example:
|
|
662
|
+
>>> tasks = [IntervalVar(size=5, name=f"t{i}") for i in range(3)]
|
|
663
|
+
>>> usage = sum(pulse(t, 2) for t in tasks)
|
|
664
|
+
>>> satisfy(cumul_range(usage, 0, 4)) # Between 0 and 4
|
|
665
|
+
"""
|
|
666
|
+
if not isinstance(cumul, CumulFunction):
|
|
667
|
+
raise TypeError(f"cumul must be a CumulFunction, got {type(cumul).__name__}")
|
|
668
|
+
if not isinstance(min_val, int) or not isinstance(max_val, int):
|
|
669
|
+
raise TypeError("min_val and max_val must be integers")
|
|
670
|
+
if min_val > max_val:
|
|
671
|
+
raise ValueError(f"min_val ({min_val}) cannot exceed max_val ({max_val})")
|
|
672
|
+
|
|
673
|
+
# For simple case min_val=0, use the <= operator which returns pycsp3 constraint
|
|
674
|
+
if min_val == 0:
|
|
675
|
+
return cumul <= max_val
|
|
676
|
+
|
|
677
|
+
# For general range constraints, return CumulConstraint
|
|
678
|
+
return CumulConstraint(
|
|
679
|
+
cumul=cumul,
|
|
680
|
+
constraint_type=CumulConstraintType.RANGE,
|
|
681
|
+
min_bound=min_val,
|
|
682
|
+
max_bound=max_val,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def always_in(
|
|
687
|
+
cumul: CumulFunction,
|
|
688
|
+
interval_or_range: IntervalVar | tuple[int, int],
|
|
689
|
+
min_val: int,
|
|
690
|
+
max_val: int,
|
|
691
|
+
) -> CumulConstraint:
|
|
692
|
+
"""
|
|
693
|
+
Constrain cumulative function within a time range.
|
|
694
|
+
|
|
695
|
+
The cumulative function must satisfy min_val <= cumul <= max_val
|
|
696
|
+
during the specified interval or fixed time range.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
cumul: The cumulative function.
|
|
700
|
+
interval_or_range: Either an IntervalVar or a (start, end) tuple.
|
|
701
|
+
min_val: Minimum allowed value during the range.
|
|
702
|
+
max_val: Maximum allowed value during the range.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
A CumulConstraint representing the always_in constraint.
|
|
706
|
+
|
|
707
|
+
Raises:
|
|
708
|
+
TypeError: If arguments have wrong types.
|
|
709
|
+
ValueError: If min_val > max_val.
|
|
710
|
+
|
|
711
|
+
Example:
|
|
712
|
+
>>> usage = sum(pulse(t, 2) for t in tasks)
|
|
713
|
+
>>> # During maintenance window, only 2 units available
|
|
714
|
+
>>> satisfy(always_in(usage, (100, 200), 0, 2))
|
|
715
|
+
>>> # During task execution, keep minimum level
|
|
716
|
+
>>> satisfy(always_in(usage, task, 1, 5))
|
|
717
|
+
"""
|
|
718
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
719
|
+
|
|
720
|
+
if not isinstance(cumul, CumulFunction):
|
|
721
|
+
raise TypeError(f"cumul must be a CumulFunction, got {type(cumul).__name__}")
|
|
722
|
+
if not isinstance(min_val, int) or not isinstance(max_val, int):
|
|
723
|
+
raise TypeError("min_val and max_val must be integers")
|
|
724
|
+
if min_val > max_val:
|
|
725
|
+
raise ValueError(f"min_val ({min_val}) cannot exceed max_val ({max_val})")
|
|
726
|
+
|
|
727
|
+
if isinstance(interval_or_range, IntervalVar):
|
|
728
|
+
return CumulConstraint(
|
|
729
|
+
cumul=cumul,
|
|
730
|
+
constraint_type=CumulConstraintType.ALWAYS_IN,
|
|
731
|
+
min_bound=min_val,
|
|
732
|
+
max_bound=max_val,
|
|
733
|
+
interval=interval_or_range,
|
|
734
|
+
)
|
|
735
|
+
elif isinstance(interval_or_range, tuple) and len(interval_or_range) == 2:
|
|
736
|
+
start, end = interval_or_range
|
|
737
|
+
if not isinstance(start, int) or not isinstance(end, int):
|
|
738
|
+
raise TypeError("Time range must be a tuple of integers")
|
|
739
|
+
if start > end:
|
|
740
|
+
raise ValueError(f"start ({start}) cannot exceed end ({end})")
|
|
741
|
+
return CumulConstraint(
|
|
742
|
+
cumul=cumul,
|
|
743
|
+
constraint_type=CumulConstraintType.ALWAYS_IN,
|
|
744
|
+
min_bound=min_val,
|
|
745
|
+
max_bound=max_val,
|
|
746
|
+
start_time=start,
|
|
747
|
+
end_time=end,
|
|
748
|
+
)
|
|
749
|
+
else:
|
|
750
|
+
raise TypeError(
|
|
751
|
+
"interval_or_range must be an IntervalVar or (start, end) tuple"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# =============================================================================
|
|
756
|
+
# Cumulative Accessor Functions
|
|
757
|
+
# =============================================================================
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def height_at_start(
|
|
761
|
+
interval: IntervalVar,
|
|
762
|
+
cumul: CumulFunction,
|
|
763
|
+
absent_value: int = 0,
|
|
764
|
+
) -> CumulHeightExpr:
|
|
765
|
+
"""
|
|
766
|
+
Get the cumulative function height at the start of an interval.
|
|
767
|
+
|
|
768
|
+
Returns an expression representing the value of the cumulative
|
|
769
|
+
function at the start time of the interval.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
interval: The interval variable.
|
|
773
|
+
cumul: The cumulative function.
|
|
774
|
+
absent_value: Value to use if interval is absent (default: 0).
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
An expression for the height at interval start.
|
|
778
|
+
|
|
779
|
+
Example:
|
|
780
|
+
>>> usage = sum(pulse(t, 2) for t in tasks)
|
|
781
|
+
>>> h = height_at_start(task, usage)
|
|
782
|
+
>>> # h represents the resource level when task starts
|
|
783
|
+
"""
|
|
784
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
785
|
+
|
|
786
|
+
if not isinstance(interval, IntervalVar):
|
|
787
|
+
raise TypeError(f"interval must be an IntervalVar, got {type(interval).__name__}")
|
|
788
|
+
if not isinstance(cumul, CumulFunction):
|
|
789
|
+
raise TypeError(f"cumul must be a CumulFunction, got {type(cumul).__name__}")
|
|
790
|
+
|
|
791
|
+
return CumulHeightExpr(
|
|
792
|
+
expr_type=CumulHeightType.AT_START,
|
|
793
|
+
interval=interval,
|
|
794
|
+
cumul=cumul,
|
|
795
|
+
absent_value=absent_value,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def height_at_end(
|
|
800
|
+
interval: IntervalVar,
|
|
801
|
+
cumul: CumulFunction,
|
|
802
|
+
absent_value: int = 0,
|
|
803
|
+
) -> CumulHeightExpr:
|
|
804
|
+
"""
|
|
805
|
+
Get the cumulative function height at the end of an interval.
|
|
806
|
+
|
|
807
|
+
Returns an expression representing the value of the cumulative
|
|
808
|
+
function at the end time of the interval.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
interval: The interval variable.
|
|
812
|
+
cumul: The cumulative function.
|
|
813
|
+
absent_value: Value to use if interval is absent (default: 0).
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
An expression for the height at interval end.
|
|
817
|
+
|
|
818
|
+
Example:
|
|
819
|
+
>>> usage = sum(pulse(t, 2) for t in tasks)
|
|
820
|
+
>>> h = height_at_end(task, usage)
|
|
821
|
+
>>> # h represents the resource level when task ends
|
|
822
|
+
"""
|
|
823
|
+
from pycsp3_scheduling.variables.interval import IntervalVar
|
|
824
|
+
|
|
825
|
+
if not isinstance(interval, IntervalVar):
|
|
826
|
+
raise TypeError(f"interval must be an IntervalVar, got {type(interval).__name__}")
|
|
827
|
+
if not isinstance(cumul, CumulFunction):
|
|
828
|
+
raise TypeError(f"cumul must be a CumulFunction, got {type(cumul).__name__}")
|
|
829
|
+
|
|
830
|
+
return CumulHeightExpr(
|
|
831
|
+
expr_type=CumulHeightType.AT_END,
|
|
832
|
+
interval=interval,
|
|
833
|
+
cumul=cumul,
|
|
834
|
+
absent_value=absent_value,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
class CumulHeightType(Enum):
|
|
839
|
+
"""Types of cumulative height expressions."""
|
|
840
|
+
|
|
841
|
+
AT_START = auto()
|
|
842
|
+
AT_END = auto()
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
@dataclass
|
|
846
|
+
class CumulHeightExpr:
|
|
847
|
+
"""
|
|
848
|
+
Expression for cumulative function height at a point.
|
|
849
|
+
|
|
850
|
+
Represents the value of a cumulative function at the start or
|
|
851
|
+
end of an interval.
|
|
852
|
+
"""
|
|
853
|
+
|
|
854
|
+
expr_type: CumulHeightType
|
|
855
|
+
interval: IntervalVar
|
|
856
|
+
cumul: CumulFunction
|
|
857
|
+
absent_value: int = 0
|
|
858
|
+
|
|
859
|
+
def __repr__(self) -> str:
|
|
860
|
+
"""String representation."""
|
|
861
|
+
name = self.interval.name if self.interval else "?"
|
|
862
|
+
if self.expr_type == CumulHeightType.AT_START:
|
|
863
|
+
return f"height_at_start({name}, {self.cumul})"
|
|
864
|
+
else:
|
|
865
|
+
return f"height_at_end({name}, {self.cumul})"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# =============================================================================
|
|
869
|
+
# Registry for Cumulative Functions
|
|
870
|
+
# =============================================================================
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
_cumul_registry: list[CumulFunction] = []
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def register_cumul(cumul: CumulFunction) -> None:
|
|
877
|
+
"""Register a cumulative function."""
|
|
878
|
+
if cumul not in _cumul_registry:
|
|
879
|
+
_cumul_registry.append(cumul)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def get_registered_cumuls() -> list[CumulFunction]:
|
|
883
|
+
"""Get all registered cumulative functions."""
|
|
884
|
+
return list(_cumul_registry)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def clear_cumul_registry() -> None:
|
|
888
|
+
"""Clear the cumulative function registry."""
|
|
889
|
+
_cumul_registry.clear()
|
|
890
|
+
CumulFunction._id_counter = 0
|
|
891
|
+
CumulExpr._id_counter = 0
|