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,227 @@
1
+ """
2
+ Cumulative constraints for resource modeling.
3
+
4
+ This module provides constraints on cumulative functions that integrate
5
+ with pycsp3's constraint system.
6
+
7
+ The key challenge is that pycsp3 doesn't have native cumulative functions,
8
+ so we decompose them into discrete time-indexed constraints or use the
9
+ Cumulative global constraint where applicable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Sequence, Union
15
+
16
+ from pycsp3_scheduling.constraints._pycsp3 import length_value, presence_var, start_var
17
+ from pycsp3_scheduling.functions.cumul_functions import (
18
+ CumulConstraint,
19
+ CumulConstraintType,
20
+ CumulExpr,
21
+ CumulExprType,
22
+ CumulFunction,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from pycsp3_scheduling.variables.interval import IntervalVar
27
+
28
+
29
+ def _get_node_builders():
30
+ """Import and return pycsp3 Node building utilities."""
31
+ from pycsp3.classes.nodes import Node, TypeNode
32
+
33
+ return Node, TypeNode
34
+
35
+
36
+ def _build_end_expr(interval, Node, TypeNode):
37
+ """Build end expression: start + length."""
38
+ start = start_var(interval)
39
+ length = length_value(interval)
40
+ if isinstance(length, int) and length == 0:
41
+ return start
42
+ return Node.build(TypeNode.ADD, start, length)
43
+
44
+
45
+ def _is_simple_pulse_cumul(cumul: CumulFunction) -> bool:
46
+ """Check if cumulative function is a simple sum of pulses with fixed heights."""
47
+ for expr in cumul.expressions:
48
+ if expr.expr_type == CumulExprType.NEG:
49
+ # Check the negated expression
50
+ if expr.operands and expr.operands[0].expr_type != CumulExprType.PULSE:
51
+ return False
52
+ if expr.operands and expr.operands[0].is_variable_height:
53
+ return False
54
+ elif expr.expr_type != CumulExprType.PULSE:
55
+ return False
56
+ elif expr.is_variable_height:
57
+ return False
58
+ return True
59
+
60
+
61
+ def _get_pulse_data(cumul: CumulFunction) -> tuple[list, list[int], list[int]]:
62
+ """Extract intervals, heights, and lengths from a pulse-only cumulative."""
63
+ intervals = []
64
+ heights = []
65
+
66
+ for expr in cumul.expressions:
67
+ if expr.expr_type == CumulExprType.NEG:
68
+ inner = expr.operands[0]
69
+ intervals.append(inner.interval)
70
+ heights.append(-(inner.height or inner.height_min))
71
+ else:
72
+ intervals.append(expr.interval)
73
+ heights.append(expr.height or expr.height_min)
74
+
75
+ return intervals, heights
76
+
77
+
78
+ def build_cumul_constraint(constraint: CumulConstraint) -> list:
79
+ """
80
+ Build pycsp3 constraints from a CumulConstraint.
81
+
82
+ For simple pulse-based cumulative functions with capacity constraints,
83
+ uses pycsp3's Cumulative global constraint. For more complex cases,
84
+ decomposes into time-indexed constraints.
85
+
86
+ Args:
87
+ constraint: The cumulative constraint to build.
88
+
89
+ Returns:
90
+ List of pycsp3 constraint nodes or ECtr objects.
91
+ """
92
+ cumul = constraint.cumul
93
+
94
+ # Handle simple capacity constraints on pulse cumulative functions
95
+ if (
96
+ constraint.constraint_type in (CumulConstraintType.LE, CumulConstraintType.RANGE)
97
+ and _is_simple_pulse_cumul(cumul)
98
+ and cumul.expressions
99
+ ):
100
+ return _build_cumulative_constraint(constraint)
101
+
102
+ # For other cases, use decomposition
103
+ return _build_decomposed_constraint(constraint)
104
+
105
+
106
+ def _build_cumulative_constraint(constraint: CumulConstraint) -> list:
107
+ """
108
+ Build using pycsp3's Cumulative global constraint.
109
+
110
+ Cumulative(origins, lengths, heights) <= limit
111
+ """
112
+ from pycsp3 import Cumulative
113
+
114
+ cumul = constraint.cumul
115
+ intervals, heights = _get_pulse_data(cumul)
116
+
117
+ # Filter out negative heights (not supported by standard Cumulative)
118
+ if any(h < 0 for h in heights):
119
+ return _build_decomposed_constraint(constraint)
120
+
121
+ # Get pycsp3 variables
122
+ origins = [start_var(iv) for iv in intervals]
123
+ lengths = [length_value(iv) for iv in intervals]
124
+
125
+ if constraint.constraint_type == CumulConstraintType.LE:
126
+ limit = constraint.bound
127
+ elif constraint.constraint_type == CumulConstraintType.RANGE:
128
+ # Cumulative only supports upper bound directly
129
+ # For lower bound, we'd need decomposition
130
+ if constraint.min_bound > 0:
131
+ return _build_decomposed_constraint(constraint)
132
+ limit = constraint.max_bound
133
+ else:
134
+ return _build_decomposed_constraint(constraint)
135
+
136
+ # Build the Cumulative constraint
137
+ return [Cumulative(origins=origins, lengths=lengths, heights=heights) <= limit]
138
+
139
+
140
+ def _build_decomposed_constraint(constraint: CumulConstraint) -> list:
141
+ """
142
+ Build decomposed constraints for complex cumulative functions.
143
+
144
+ This handles:
145
+ - Variable heights
146
+ - Step functions (step_at_start, step_at_end, step_at)
147
+ - ALWAYS_IN constraints
148
+ - GE/GT constraints
149
+
150
+ For now, returns a placeholder as full decomposition is complex.
151
+ """
152
+ Node, TypeNode = _get_node_builders()
153
+ constraints = []
154
+
155
+ cumul = constraint.cumul
156
+
157
+ # For GE constraints: can negate and use LE
158
+ if constraint.constraint_type == CumulConstraintType.GE:
159
+ # cumul >= bound <=> -cumul <= -bound
160
+ # But we need to handle this properly with the actual expressions
161
+ pass
162
+
163
+ # For ALWAYS_IN: constrain during specific time range
164
+ if constraint.constraint_type == CumulConstraintType.ALWAYS_IN:
165
+ # This requires time-indexed reasoning
166
+ # For fixed time ranges, we can reason about which intervals overlap
167
+ pass
168
+
169
+ # Default: return empty list (constraint not yet enforced at pycsp3 level)
170
+ # The CumulConstraint object still exists for model introspection
171
+ return constraints
172
+
173
+
174
+ def SeqCumulative(
175
+ intervals: Sequence[IntervalVar],
176
+ heights: Sequence[int],
177
+ capacity: int,
178
+ ) -> list:
179
+ """
180
+ Resource capacity constraint using pycsp3's Cumulative.
181
+
182
+ Ensures that the sum of heights of intervals executing at any time
183
+ does not exceed the capacity.
184
+
185
+ This is named SeqCumulative to avoid clashing with pycsp3's native
186
+ Cumulative constraint.
187
+
188
+ Args:
189
+ intervals: List of interval variables.
190
+ heights: List of resource heights (demands) for each interval.
191
+ capacity: Maximum capacity of the resource.
192
+
193
+ Returns:
194
+ List containing the Cumulative constraint.
195
+
196
+ Raises:
197
+ ValueError: If intervals and heights have different lengths.
198
+ TypeError: If inputs have wrong types.
199
+
200
+ Example:
201
+ >>> tasks = [IntervalVar(size=5, name=f"t{i}") for i in range(3)]
202
+ >>> demands = [2, 3, 1]
203
+ >>> satisfy(SeqCumulative(tasks, demands, capacity=4))
204
+ """
205
+ from pycsp3_scheduling.variables.interval import IntervalVar
206
+ from pycsp3 import Cumulative as Cumul
207
+
208
+ if len(intervals) != len(heights):
209
+ raise ValueError(
210
+ f"intervals ({len(intervals)}) and heights ({len(heights)}) must have same length"
211
+ )
212
+
213
+ for i, iv in enumerate(intervals):
214
+ if not isinstance(iv, IntervalVar):
215
+ raise TypeError(f"intervals[{i}] must be an IntervalVar")
216
+
217
+ for i, h in enumerate(heights):
218
+ if not isinstance(h, int):
219
+ raise TypeError(f"heights[{i}] must be an int")
220
+
221
+ if not isinstance(capacity, int):
222
+ raise TypeError(f"capacity must be an int, got {type(capacity).__name__}")
223
+
224
+ origins = [start_var(iv) for iv in intervals]
225
+ lengths = [length_value(iv) for iv in intervals]
226
+
227
+ return [Cumul(origins=origins, lengths=lengths, heights=list(heights)) <= capacity]
@@ -0,0 +1,382 @@
1
+ """
2
+ Grouping constraints for interval variables.
3
+
4
+ This module provides constraints that relate a main interval to a group of intervals:
5
+
6
+ 1. **span(main, subtasks)**: Main interval spans all present subtasks
7
+ - start(main) = min{start(i) : i present}
8
+ - end(main) = max{end(i) : i present}
9
+
10
+ 2. **alternative(main, alternatives, cardinality=1)**: Select exactly k alternatives
11
+ - Exactly `cardinality` intervals from `alternatives` are present
12
+ - Selected intervals have same start/end as main
13
+
14
+ 3. **synchronize(main, intervals)**: All present intervals synchronize with main
15
+ - All present intervals in array have same start/end as main
16
+
17
+ All constraints handle optional (absent) intervals correctly:
18
+ - When main is absent, all related intervals are absent
19
+ - When all subtasks are absent, main is absent (for span)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Sequence
25
+
26
+ from pycsp3_scheduling.constraints._pycsp3 import (
27
+ length_value,
28
+ presence_var,
29
+ start_var,
30
+ )
31
+ from pycsp3_scheduling.variables.interval import IntervalVar
32
+
33
+ if TYPE_CHECKING:
34
+ pass
35
+
36
+
37
+ def _validate_interval(interval: IntervalVar, name: str) -> None:
38
+ """Validate that argument is an IntervalVar."""
39
+ if not isinstance(interval, IntervalVar):
40
+ raise TypeError(f"{name} must be an IntervalVar, got {type(interval).__name__}")
41
+
42
+
43
+ def _validate_interval_list(
44
+ intervals: Sequence[IntervalVar], name: str
45
+ ) -> list[IntervalVar]:
46
+ """Validate and convert interval sequence."""
47
+ if not isinstance(intervals, (list, tuple)):
48
+ raise TypeError(f"{name} must be a list or tuple of IntervalVar")
49
+ result = list(intervals)
50
+ for i, interval in enumerate(result):
51
+ if not isinstance(interval, IntervalVar):
52
+ raise TypeError(
53
+ f"{name}[{i}] must be an IntervalVar, got {type(interval).__name__}"
54
+ )
55
+ if len(result) == 0:
56
+ raise ValueError(f"{name} cannot be empty")
57
+ return result
58
+
59
+
60
+ def _get_node_builders():
61
+ """Import and return pycsp3 Node building utilities."""
62
+ from pycsp3.classes.nodes import Node, TypeNode
63
+
64
+ return Node, TypeNode
65
+
66
+
67
+ def _build_end_expr(interval: IntervalVar, Node, TypeNode):
68
+ """Build end expression: start + length."""
69
+ start = start_var(interval)
70
+ length = length_value(interval)
71
+ if isinstance(length, int) and length == 0:
72
+ return start
73
+ return Node.build(TypeNode.ADD, start, length)
74
+
75
+
76
+ # =============================================================================
77
+ # Span Constraint
78
+ # =============================================================================
79
+
80
+
81
+ def span(main: IntervalVar, subtasks: Sequence[IntervalVar]) -> list:
82
+ """
83
+ Constrain main interval to span all present subtasks.
84
+
85
+ The main interval covers exactly the time range occupied by its subtasks:
86
+ - start(main) = min{start(i) : i present in subtasks}
87
+ - end(main) = max{end(i) : i present in subtasks}
88
+
89
+ Semantics for optional intervals:
90
+ - If main is present, at least one subtask must be present
91
+ - If main is absent, all subtasks must be absent
92
+ - If all subtasks are absent, main must be absent
93
+
94
+ Args:
95
+ main: The spanning interval variable.
96
+ subtasks: List of interval variables to be spanned.
97
+
98
+ Returns:
99
+ List of pycsp3 constraint nodes.
100
+
101
+ Raises:
102
+ TypeError: If main is not IntervalVar or subtasks contains non-IntervalVar.
103
+ ValueError: If subtasks is empty.
104
+
105
+ Example:
106
+ >>> main_task = IntervalVar(name="project")
107
+ >>> phases = [IntervalVar(size=10, name=f"phase_{i}") for i in range(3)]
108
+ >>> satisfy(span(main_task, phases)) # project spans all phases
109
+ """
110
+ _validate_interval(main, "main")
111
+ subtasks_list = _validate_interval_list(subtasks, "subtasks")
112
+ Node, TypeNode = _get_node_builders()
113
+
114
+ constraints = []
115
+ main_start = start_var(main)
116
+ main_end = _build_end_expr(main, Node, TypeNode)
117
+
118
+ # Collect start and end expressions for subtasks
119
+ subtask_starts = [start_var(t) for t in subtasks_list]
120
+ subtask_ends = [_build_end_expr(t, Node, TypeNode) for t in subtasks_list]
121
+
122
+ has_optional_main = main.optional
123
+ has_optional_subtasks = any(t.optional for t in subtasks_list)
124
+
125
+ if not has_optional_main and not has_optional_subtasks:
126
+ # Simple case: all intervals are mandatory
127
+ # start(main) = min(subtask starts)
128
+ # end(main) = max(subtask ends)
129
+ if len(subtasks_list) == 1:
130
+ # Single subtask: direct equality
131
+ constraints.append(Node.build(TypeNode.EQ, main_start, subtask_starts[0]))
132
+ constraints.append(Node.build(TypeNode.EQ, main_end, subtask_ends[0]))
133
+ else:
134
+ min_start = Node.build(TypeNode.MIN, subtask_starts)
135
+ max_end = Node.build(TypeNode.MAX, subtask_ends)
136
+ constraints.append(Node.build(TypeNode.EQ, main_start, min_start))
137
+ constraints.append(Node.build(TypeNode.EQ, main_end, max_end))
138
+ else:
139
+ # Complex case with optional intervals
140
+ # We need conditional constraints based on presence
141
+
142
+ main_presence = presence_var(main)
143
+ subtask_presences = [presence_var(t) for t in subtasks_list]
144
+
145
+ # For each subtask: if present, main must contain it
146
+ for i, subtask in enumerate(subtasks_list):
147
+ sub_start = subtask_starts[i]
148
+ sub_end = subtask_ends[i]
149
+ sub_presence = subtask_presences[i]
150
+
151
+ if subtask.optional:
152
+ # If subtask present: main_start <= sub_start AND sub_end <= main_end
153
+ # Encoded as: (sub_presence == 0) OR (main_start <= sub_start)
154
+ cond1 = Node.build(
155
+ TypeNode.OR,
156
+ Node.build(TypeNode.EQ, sub_presence, 0),
157
+ Node.build(TypeNode.LE, main_start, sub_start),
158
+ )
159
+ cond2 = Node.build(
160
+ TypeNode.OR,
161
+ Node.build(TypeNode.EQ, sub_presence, 0),
162
+ Node.build(TypeNode.LE, sub_end, main_end),
163
+ )
164
+ constraints.append(cond1)
165
+ constraints.append(cond2)
166
+
167
+ # If subtask present, main must be present
168
+ if has_optional_main:
169
+ # sub_presence <= main_presence (if sub present, main present)
170
+ constraints.append(
171
+ Node.build(TypeNode.LE, sub_presence, main_presence)
172
+ )
173
+ else:
174
+ # Mandatory subtask: main must contain it
175
+ constraints.append(Node.build(TypeNode.LE, main_start, sub_start))
176
+ constraints.append(Node.build(TypeNode.LE, sub_end, main_end))
177
+
178
+ if has_optional_main:
179
+ # If main is present, at least one subtask must be present
180
+ # main_presence <= sum(subtask_presences)
181
+ # Equivalently: main_presence == 0 OR sum(subtask_presences) >= 1
182
+ if has_optional_subtasks:
183
+ sum_presences = Node.build(TypeNode.ADD, *subtask_presences)
184
+ constraints.append(
185
+ Node.build(
186
+ TypeNode.OR,
187
+ Node.build(TypeNode.EQ, main_presence, 0),
188
+ Node.build(TypeNode.GE, sum_presences, 1),
189
+ )
190
+ )
191
+
192
+ # Main start/end should match the actual span when present
193
+ # This is enforced by the containment constraints above plus tightness
194
+
195
+ return constraints
196
+
197
+
198
+ # =============================================================================
199
+ # Alternative Constraint
200
+ # =============================================================================
201
+
202
+
203
+ def alternative(
204
+ main: IntervalVar,
205
+ alternatives: Sequence[IntervalVar],
206
+ cardinality: int = 1,
207
+ ) -> list:
208
+ """
209
+ Constrain exactly k alternatives to be selected and match the main interval.
210
+
211
+ When main is present, exactly `cardinality` intervals from `alternatives`
212
+ are present, and they have the same start and end times as main.
213
+
214
+ Semantics:
215
+ - If main is present: exactly `cardinality` alternatives are present
216
+ - Selected alternatives have start(alt) == start(main) and end(alt) == end(main)
217
+ - If main is absent: all alternatives are absent
218
+
219
+ Args:
220
+ main: The main interval variable.
221
+ alternatives: List of alternative interval variables.
222
+ cardinality: Number of alternatives to select (default 1).
223
+
224
+ Returns:
225
+ List of pycsp3 constraint nodes.
226
+
227
+ Raises:
228
+ TypeError: If main is not IntervalVar or alternatives contains non-IntervalVar.
229
+ ValueError: If alternatives is empty or cardinality is invalid.
230
+
231
+ Example:
232
+ >>> main_op = IntervalVar(size=10, name="operation")
233
+ >>> machines = [IntervalVar(size=10, optional=True, name=f"m{i}") for i in range(3)]
234
+ >>> satisfy(alternative(main_op, machines)) # Assign to exactly one machine
235
+ """
236
+ _validate_interval(main, "main")
237
+ alts_list = _validate_interval_list(alternatives, "alternatives")
238
+
239
+ if not isinstance(cardinality, int) or cardinality < 1:
240
+ raise ValueError(f"cardinality must be a positive integer, got {cardinality}")
241
+ if cardinality > len(alts_list):
242
+ raise ValueError(
243
+ f"cardinality ({cardinality}) cannot exceed number of alternatives "
244
+ f"({len(alts_list)})"
245
+ )
246
+
247
+ # All alternatives must be optional for alternative constraint to make sense
248
+ for i, alt in enumerate(alts_list):
249
+ if not alt.optional:
250
+ raise ValueError(
251
+ f"alternatives[{i}] ({alt.name}) must be optional for alternative constraint"
252
+ )
253
+
254
+ Node, TypeNode = _get_node_builders()
255
+ constraints = []
256
+
257
+ main_start = start_var(main)
258
+ main_end = _build_end_expr(main, Node, TypeNode)
259
+ main_presence = presence_var(main)
260
+
261
+ alt_presences = [presence_var(alt) for alt in alts_list]
262
+
263
+ # Sum of alternative presences equals cardinality when main is present
264
+ # sum(alt_presences) == cardinality * main_presence
265
+ if len(alt_presences) == 1:
266
+ sum_presences = alt_presences[0]
267
+ else:
268
+ sum_presences = Node.build(TypeNode.ADD, *alt_presences)
269
+ if main.optional:
270
+ # sum(alt_presences) == cardinality * main_presence
271
+ rhs = Node.build(TypeNode.MUL, cardinality, main_presence)
272
+ constraints.append(Node.build(TypeNode.EQ, sum_presences, rhs))
273
+ else:
274
+ # Main is mandatory, so exactly cardinality alternatives must be present
275
+ constraints.append(Node.build(TypeNode.EQ, sum_presences, cardinality))
276
+
277
+ # Each selected alternative must match main's timing
278
+ for i, alt in enumerate(alts_list):
279
+ alt_start = start_var(alt)
280
+ alt_end = _build_end_expr(alt, Node, TypeNode)
281
+ alt_presence = alt_presences[i]
282
+
283
+ # If alternative is present, it must match main's start
284
+ # (alt_presence == 0) OR (alt_start == main_start)
285
+ start_match = Node.build(
286
+ TypeNode.OR,
287
+ Node.build(TypeNode.EQ, alt_presence, 0),
288
+ Node.build(TypeNode.EQ, alt_start, main_start),
289
+ )
290
+ constraints.append(start_match)
291
+
292
+ # If alternative is present, it must match main's end
293
+ # (alt_presence == 0) OR (alt_end == main_end)
294
+ end_match = Node.build(
295
+ TypeNode.OR,
296
+ Node.build(TypeNode.EQ, alt_presence, 0),
297
+ Node.build(TypeNode.EQ, alt_end, main_end),
298
+ )
299
+ constraints.append(end_match)
300
+
301
+ return constraints
302
+
303
+
304
+ # =============================================================================
305
+ # Synchronize Constraint
306
+ # =============================================================================
307
+
308
+
309
+ def synchronize(main: IntervalVar, intervals: Sequence[IntervalVar]) -> list:
310
+ """
311
+ Constrain all present intervals to synchronize with the main interval.
312
+
313
+ All present intervals in the array have the same start and end times as main.
314
+
315
+ Semantics:
316
+ - If main is present: present intervals have same start/end as main
317
+ - If main is absent: all intervals in array are absent
318
+ - Intervals in array can be independently present or absent
319
+
320
+ Args:
321
+ main: The main interval variable.
322
+ intervals: List of interval variables to synchronize.
323
+
324
+ Returns:
325
+ List of pycsp3 constraint nodes.
326
+
327
+ Raises:
328
+ TypeError: If main is not IntervalVar or intervals contains non-IntervalVar.
329
+ ValueError: If intervals is empty.
330
+
331
+ Example:
332
+ >>> main = IntervalVar(size=10, name="meeting")
333
+ >>> attendees = [IntervalVar(size=10, optional=True, name=f"person_{i}") for i in range(5)]
334
+ >>> satisfy(synchronize(main, attendees)) # Present attendees sync with meeting
335
+ """
336
+ _validate_interval(main, "main")
337
+ intervals_list = _validate_interval_list(intervals, "intervals")
338
+ Node, TypeNode = _get_node_builders()
339
+
340
+ constraints = []
341
+ main_start = start_var(main)
342
+ main_end = _build_end_expr(main, Node, TypeNode)
343
+ main_presence = presence_var(main)
344
+
345
+ for i, interval in enumerate(intervals_list):
346
+ int_start = start_var(interval)
347
+ int_end = _build_end_expr(interval, Node, TypeNode)
348
+ int_presence = presence_var(interval)
349
+
350
+ if interval.optional:
351
+ # If main is absent, interval must be absent
352
+ # main_presence >= int_presence (if main absent, interval absent)
353
+ if main.optional:
354
+ constraints.append(
355
+ Node.build(TypeNode.GE, main_presence, int_presence)
356
+ )
357
+ else:
358
+ # Main is mandatory, intervals can be present or absent
359
+ pass
360
+
361
+ # If interval is present, it must match main's timing
362
+ # (int_presence == 0) OR (int_start == main_start)
363
+ start_match = Node.build(
364
+ TypeNode.OR,
365
+ Node.build(TypeNode.EQ, int_presence, 0),
366
+ Node.build(TypeNode.EQ, int_start, main_start),
367
+ )
368
+ constraints.append(start_match)
369
+
370
+ # (int_presence == 0) OR (int_end == main_end)
371
+ end_match = Node.build(
372
+ TypeNode.OR,
373
+ Node.build(TypeNode.EQ, int_presence, 0),
374
+ Node.build(TypeNode.EQ, int_end, main_end),
375
+ )
376
+ constraints.append(end_match)
377
+ else:
378
+ # Mandatory interval: must always match main
379
+ constraints.append(Node.build(TypeNode.EQ, int_start, main_start))
380
+ constraints.append(Node.build(TypeNode.EQ, int_end, main_end))
381
+
382
+ return constraints