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,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
|