up-cpse 0.1.0__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.
- cpse/CPSE.py +283 -0
- cpse/CPSEBaseEngine.py +1118 -0
- cpse/CPSETimepoints.py +1374 -0
- cpse/__init__.py +21 -0
- cpse/py.typed +0 -0
- up_cpse-0.1.0.dist-info/METADATA +139 -0
- up_cpse-0.1.0.dist-info/RECORD +9 -0
- up_cpse-0.1.0.dist-info/WHEEL +4 -0
- up_cpse-0.1.0.dist-info/licenses/LICENSE +674 -0
cpse/CPSE.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Copyright (C) 2025 PSO Unit, Fondazione Bruno Kessler
|
|
2
|
+
# This file is part of CPSE.
|
|
3
|
+
#
|
|
4
|
+
# CPSE is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# CPSE is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
#
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
import unified_planning as up
|
|
20
|
+
from ortools.sat.python import cp_model
|
|
21
|
+
from unified_planning.model import Effect, FNode, ProblemKind, timing
|
|
22
|
+
from unified_planning.model.scheduling import Activity, SchedulingProblem
|
|
23
|
+
|
|
24
|
+
from .CPSEBaseEngine import CPSEBaseEngine
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CPSE(CPSEBaseEngine):
|
|
28
|
+
"""
|
|
29
|
+
Implementation of the CPSE Engine.
|
|
30
|
+
|
|
31
|
+
This class extends the `CPSEBaseEngine` and provides concrete implementations
|
|
32
|
+
for the abstract methods defined in the base class.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, **kwargs):
|
|
36
|
+
super().__init__(**kwargs)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "CPSE"
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def supported_kind() -> ProblemKind:
|
|
44
|
+
supported_kind = CPSEBaseEngine.supported_kind()
|
|
45
|
+
supported_kind.set_scheduling("OPTIONAL_ACTIVITIES")
|
|
46
|
+
supported_kind.set_scheduling("SCOPED_CONSTRAINTS")
|
|
47
|
+
return supported_kind
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def supports(problem_kind: ProblemKind) -> bool:
|
|
51
|
+
return bool(problem_kind <= CPSE.supported_kind())
|
|
52
|
+
|
|
53
|
+
def check_if_supported_problem(self, problem: "up.model.AbstractProblem"):
|
|
54
|
+
"""
|
|
55
|
+
Checks if the given problem is a supported instance of `SchedulingProblem`.
|
|
56
|
+
Raises `NotImplementedError` if unsupported.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
problem (up.model.AbstractProblem): The problem instance to validate.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
NotImplementedError: If the problem is not supported.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
if not isinstance(problem, SchedulingProblem):
|
|
66
|
+
raise NotImplementedError(f"Problem of type {type(problem)} not supported.")
|
|
67
|
+
problem: SchedulingProblem
|
|
68
|
+
|
|
69
|
+
if not problem.discrete_time:
|
|
70
|
+
raise NotImplementedError("Continuous time not supported.")
|
|
71
|
+
|
|
72
|
+
parametric_fluent_exps = list(
|
|
73
|
+
filter(
|
|
74
|
+
lambda e: self._fluent_exp_contains_parameters(e[1].fluent),
|
|
75
|
+
problem.all_effects(),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
for fnode, _scope in problem.all_scoped_constraints():
|
|
79
|
+
parametric_fluent_exps += list(
|
|
80
|
+
self.extract_all_parametric_fluent_exp_from_fnode(fnode)
|
|
81
|
+
)
|
|
82
|
+
for _time_interval, fnode, _activity in problem.all_conditions():
|
|
83
|
+
parametric_fluent_exps += list(
|
|
84
|
+
self.extract_all_parametric_fluent_exp_from_fnode(fnode)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if len(parametric_fluent_exps) > 0:
|
|
88
|
+
raise NotImplementedError("Fluents with parameters are not supported.")
|
|
89
|
+
|
|
90
|
+
def add_constraints(self, problem: SchedulingProblem):
|
|
91
|
+
"""
|
|
92
|
+
Adds all constraints from the given scheduling problem to the model.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
problem (SchedulingProblem): The scheduling problem containing the
|
|
96
|
+
constraints to be added to the model.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
for fnode, scope in problem.all_scoped_constraints():
|
|
100
|
+
bool_var = self.add_constraint(fnode)
|
|
101
|
+
constraint_var = self.model.add_bool_and([bool_var])
|
|
102
|
+
if len(scope) > 0:
|
|
103
|
+
constraint_var.only_enforce_if(
|
|
104
|
+
[self.fnode_to_value_or_variable(fn) for fn in scope]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def add_effects(self, problem: SchedulingProblem):
|
|
108
|
+
"""
|
|
109
|
+
Adds the effects of the given scheduling problem to the model.
|
|
110
|
+
|
|
111
|
+
Adds a reservoir constraint for each fluent in the scheduling problem.
|
|
112
|
+
Each fluent's value is constrained to remain within the range [0, C],
|
|
113
|
+
where C is the maximum capacity of the fluent.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
problem (SchedulingProblem): The scheduling problem containing the
|
|
117
|
+
effects to be applied to the model.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
# map each fluent to its effects, adjusting values based on increase/decrease
|
|
121
|
+
# effect types
|
|
122
|
+
fluent_effects: dict[
|
|
123
|
+
FNode,
|
|
124
|
+
list[
|
|
125
|
+
tuple[
|
|
126
|
+
timing.Timing,
|
|
127
|
+
int,
|
|
128
|
+
cp_model.IntVar | cp_model.NotBooleanVariable | bool,
|
|
129
|
+
]
|
|
130
|
+
],
|
|
131
|
+
] = {}
|
|
132
|
+
for effect_timing, eff, activity in problem.all_effects():
|
|
133
|
+
eff: Effect
|
|
134
|
+
fluent_exp = eff.fluent
|
|
135
|
+
|
|
136
|
+
if eff.value.is_int_constant():
|
|
137
|
+
value = eff.value.int_constant_value()
|
|
138
|
+
elif eff.value.is_bool_constant():
|
|
139
|
+
value = 1 if eff.value.bool_constant_value() else 0
|
|
140
|
+
else:
|
|
141
|
+
raise NotImplementedError(
|
|
142
|
+
"Effect values must be constants of type boolean or integer."
|
|
143
|
+
)
|
|
144
|
+
if value == 0: # if value is 0, the effect is ignored
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if fluent_exp not in fluent_effects:
|
|
148
|
+
fluent_effects[fluent_exp] = []
|
|
149
|
+
|
|
150
|
+
if eff.is_increase():
|
|
151
|
+
pass
|
|
152
|
+
elif eff.is_decrease():
|
|
153
|
+
value = -value
|
|
154
|
+
else:
|
|
155
|
+
# assignment effects not supported
|
|
156
|
+
raise NotImplementedError(f"Effect kind {eff.kind} not supported.")
|
|
157
|
+
|
|
158
|
+
bool_var: cp_model.IntVar | cp_model.NotBooleanVariable | bool
|
|
159
|
+
if eff.is_conditional():
|
|
160
|
+
if activity is not None and activity.optional:
|
|
161
|
+
bool_var = self.add_constraint(
|
|
162
|
+
up.shortcuts.And(activity.present, eff.condition)
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
bool_var = self.add_constraint(eff.condition)
|
|
166
|
+
else:
|
|
167
|
+
if activity is not None and activity.optional:
|
|
168
|
+
bool_var = self.fnode_to_value_or_variable(activity.present) # type: ignore[assignment]
|
|
169
|
+
else:
|
|
170
|
+
bool_var = True
|
|
171
|
+
|
|
172
|
+
fluent_effects[fluent_exp].append((effect_timing, value, bool_var))
|
|
173
|
+
|
|
174
|
+
for fluent_exp in fluent_effects:
|
|
175
|
+
lb, ub = self._fluent_bounds[fluent_exp.fluent().name]
|
|
176
|
+
if fluent_exp not in self._fluent_initial_value:
|
|
177
|
+
raise NotImplementedError(
|
|
178
|
+
f"Fluent '{fluent_exp}' must be initialized with a constant value "
|
|
179
|
+
"of type integer or boolean."
|
|
180
|
+
)
|
|
181
|
+
init_value = self._fluent_initial_value[fluent_exp]
|
|
182
|
+
if init_value.is_int_constant():
|
|
183
|
+
init_value = init_value.int_constant_value()
|
|
184
|
+
elif init_value.is_bool_constant():
|
|
185
|
+
if not init_value.bool_constant_value():
|
|
186
|
+
init_value = 0
|
|
187
|
+
else:
|
|
188
|
+
init_value = 1
|
|
189
|
+
else:
|
|
190
|
+
raise NotImplementedError(
|
|
191
|
+
"Only integer and boolean constants are supported as initial "
|
|
192
|
+
"values for fluents."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
times: list[cp_model.LinearExprT] = [0]
|
|
196
|
+
values = [init_value]
|
|
197
|
+
actives: list[cp_model.IntVar | cp_model.NotBooleanVariable | bool] = [True]
|
|
198
|
+
for effect_timing, value, active in fluent_effects[fluent_exp]:
|
|
199
|
+
times.append(self._convert_timing_to_linear_expr(effect_timing))
|
|
200
|
+
values.append(value)
|
|
201
|
+
actives.append(active)
|
|
202
|
+
|
|
203
|
+
if lb > 0:
|
|
204
|
+
raise NotImplementedError(
|
|
205
|
+
"Fluent lower bound cannot be greater than 0."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
self.model.add_reservoir_constraint_with_active(
|
|
209
|
+
times, values, actives, lb, ub
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def add_condition(
|
|
213
|
+
self,
|
|
214
|
+
time_interval: timing.TimeInterval,
|
|
215
|
+
fnode: FNode,
|
|
216
|
+
activity: Activity | None,
|
|
217
|
+
name: str,
|
|
218
|
+
):
|
|
219
|
+
"""
|
|
220
|
+
Adds a condition to the model, enforcing that it is satisfied within a
|
|
221
|
+
specified time interval.
|
|
222
|
+
|
|
223
|
+
This method creates a constraint based on the given condition and uses the
|
|
224
|
+
`add_cumulative`
|
|
225
|
+
method to ensure it holds throughout the specified time interval.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
time_interval (timing.TimeInterval): The time interval during which the
|
|
229
|
+
condition
|
|
230
|
+
must be satisfied.
|
|
231
|
+
fnode (FNode): The FNode representing the condition to be added as a
|
|
232
|
+
constraint.
|
|
233
|
+
name (str): The name of the condition for identification within the model.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
bool_var = self.add_constraint(fnode)
|
|
237
|
+
|
|
238
|
+
start_delay = 0
|
|
239
|
+
if time_interval.lower.delay != 0:
|
|
240
|
+
start_delay += time_interval.lower.delay
|
|
241
|
+
if time_interval.is_left_open():
|
|
242
|
+
start_delay += 1
|
|
243
|
+
start = self._model_vars[time_interval.lower.timepoint] + start_delay
|
|
244
|
+
|
|
245
|
+
# add 1 to end because `add_cumulative` enforces the constraint for t in [start,
|
|
246
|
+
# end),
|
|
247
|
+
# but we want the constraint to be enforced also at the end
|
|
248
|
+
end_delay = 1
|
|
249
|
+
if time_interval.upper.delay != 0:
|
|
250
|
+
end_delay += time_interval.upper.delay
|
|
251
|
+
if time_interval.is_right_open():
|
|
252
|
+
end_delay -= 1
|
|
253
|
+
end = self._model_vars[time_interval.upper.timepoint] + end_delay
|
|
254
|
+
|
|
255
|
+
duration = self.model.new_int_var(
|
|
256
|
+
self.lower_bound,
|
|
257
|
+
self.upper_bound,
|
|
258
|
+
f"{name}_duration",
|
|
259
|
+
)
|
|
260
|
+
if activity is not None and activity.optional:
|
|
261
|
+
interval_var = self.model.new_optional_interval_var(
|
|
262
|
+
start,
|
|
263
|
+
duration,
|
|
264
|
+
end,
|
|
265
|
+
self._model_vars[activity.present.presence()],
|
|
266
|
+
name,
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
interval_var = self.model.new_interval_var(start, duration, end, name)
|
|
270
|
+
|
|
271
|
+
self.model.add_cumulative([interval_var], [bool_var.negated()], 0)
|
|
272
|
+
|
|
273
|
+
def add_conditions(self, problem: SchedulingProblem):
|
|
274
|
+
"""
|
|
275
|
+
Adds the conditions of the given scheduling problem to the model.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
problem (SchedulingProblem): The scheduling problem containing the
|
|
279
|
+
conditions to be added to the model.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
for i, (time_interval, fnode, activity) in enumerate(problem.all_conditions()):
|
|
283
|
+
self.add_condition(time_interval, fnode, activity, f"condition{i}")
|