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.
@@ -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