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,494 @@
1
+ """
2
+ State functions for modeling discrete states over time.
3
+
4
+ A state function represents a resource that can be in different states
5
+ over time, with optional transition constraints between states.
6
+
7
+ Use cases:
8
+ - Machine modes (setup, processing, idle, maintenance)
9
+ - Room configurations (lecture, exam, meeting)
10
+ - Worker skills/roles
11
+ - Any resource with discrete, mutually exclusive states
12
+
13
+ Example:
14
+ >>> machine_state = StateFunction(name="machine")
15
+ >>> # Task requires machine in state 1
16
+ >>> satisfy(always_equal(machine_state, task, 1))
17
+ >>> # Define valid transitions with durations
18
+ >>> transitions = TransitionMatrix([
19
+ ... [0, 5, 10], # From state 0: 0->0=0, 0->1=5, 0->2=10
20
+ ... [5, 0, 3], # From state 1: 1->0=5, 1->1=0, 1->2=3
21
+ ... [10, 3, 0], # From state 2: 2->0=10, 2->1=3, 2->2=0
22
+ ... ])
23
+ >>> machine_state = StateFunction(name="machine", transitions=transitions)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+ from enum import Enum, auto
30
+ from typing import TYPE_CHECKING, Sequence
31
+
32
+ if TYPE_CHECKING:
33
+ from pycsp3_scheduling.variables.interval import IntervalVar
34
+
35
+
36
+ # =============================================================================
37
+ # Transition Matrix
38
+ # =============================================================================
39
+
40
+
41
+ @dataclass
42
+ class TransitionMatrix:
43
+ """
44
+ Transition matrix defining valid state transitions and durations.
45
+
46
+ A transition matrix specifies the time required to transition from
47
+ one state to another. A value of -1 (or FORBIDDEN) indicates that
48
+ the transition is not allowed.
49
+
50
+ Attributes:
51
+ matrix: 2D list of transition times. matrix[i][j] is the time
52
+ to transition from state i to state j.
53
+ name: Optional name for the matrix.
54
+
55
+ Example:
56
+ >>> # 3 states with symmetric transition times
57
+ >>> tm = TransitionMatrix([
58
+ ... [0, 5, 10],
59
+ ... [5, 0, 3],
60
+ ... [10, 3, 0],
61
+ ... ])
62
+ >>> tm[0, 1] # Time from state 0 to state 1
63
+ 5
64
+ """
65
+
66
+ matrix: list[list[int]]
67
+ name: str | None = None
68
+ _id: int = field(default=-1, repr=False)
69
+
70
+ # Special value indicating forbidden transition
71
+ FORBIDDEN: int = -1
72
+
73
+ def __post_init__(self) -> None:
74
+ """Validate and assign unique ID."""
75
+ self._validate()
76
+ if self._id == -1:
77
+ self._id = TransitionMatrix._get_next_id()
78
+
79
+ def _validate(self) -> None:
80
+ """Validate the transition matrix."""
81
+ if not self.matrix:
82
+ raise ValueError("Transition matrix cannot be empty")
83
+
84
+ n = len(self.matrix)
85
+ for i, row in enumerate(self.matrix):
86
+ if len(row) != n:
87
+ raise ValueError(
88
+ f"Transition matrix must be square. "
89
+ f"Row {i} has {len(row)} elements, expected {n}"
90
+ )
91
+ for j, val in enumerate(row):
92
+ if not isinstance(val, int):
93
+ raise TypeError(
94
+ f"Transition matrix values must be integers, "
95
+ f"got {type(val).__name__} at [{i}][{j}]"
96
+ )
97
+
98
+ @staticmethod
99
+ def _get_next_id() -> int:
100
+ """Get next unique ID."""
101
+ current = getattr(TransitionMatrix, "_id_counter", 0)
102
+ TransitionMatrix._id_counter = current + 1
103
+ return current
104
+
105
+ @property
106
+ def size(self) -> int:
107
+ """Number of states (dimension of the matrix)."""
108
+ return len(self.matrix)
109
+
110
+ def __getitem__(self, key: tuple[int, int]) -> int:
111
+ """Get transition time from state i to state j."""
112
+ i, j = key
113
+ return self.matrix[i][j]
114
+
115
+ def __setitem__(self, key: tuple[int, int], value: int) -> None:
116
+ """Set transition time from state i to state j."""
117
+ i, j = key
118
+ self.matrix[i][j] = value
119
+
120
+ def is_forbidden(self, from_state: int, to_state: int) -> bool:
121
+ """Check if transition from from_state to to_state is forbidden."""
122
+ return self.matrix[from_state][to_state] == self.FORBIDDEN
123
+
124
+ def get_row(self, state: int) -> list[int]:
125
+ """Get all transition times from a given state."""
126
+ return self.matrix[state]
127
+
128
+ def get_column(self, state: int) -> list[int]:
129
+ """Get all transition times to a given state."""
130
+ return [row[state] for row in self.matrix]
131
+
132
+ def __repr__(self) -> str:
133
+ """String representation."""
134
+ if self.name:
135
+ return f"TransitionMatrix({self.name}, {self.size}x{self.size})"
136
+ return f"TransitionMatrix({self.size}x{self.size})"
137
+
138
+
139
+ # =============================================================================
140
+ # State Function
141
+ # =============================================================================
142
+
143
+
144
+ @dataclass
145
+ class StateFunction:
146
+ """
147
+ State function representing a discrete state over time.
148
+
149
+ A state function can be in different integer states at different times.
150
+ Tasks can require specific states during their execution, and transitions
151
+ between states can have associated times defined by a transition matrix.
152
+
153
+ Attributes:
154
+ name: Name of the state function.
155
+ transitions: Optional transition matrix defining transition times.
156
+ initial_state: Initial state at time 0 (default: no specific state).
157
+ states: Set of valid state values (inferred from transitions if not given).
158
+
159
+ Example:
160
+ >>> machine = StateFunction(name="machine_mode")
161
+ >>> # Machine must be in state 2 during task execution
162
+ >>> satisfy(always_equal(machine, task, 2))
163
+ """
164
+
165
+ name: str
166
+ transitions: TransitionMatrix | None = None
167
+ initial_state: int | None = None
168
+ states: set[int] | None = None
169
+ _id: int = field(default=-1, repr=False)
170
+
171
+ def __post_init__(self) -> None:
172
+ """Initialize and validate."""
173
+ if self._id == -1:
174
+ self._id = StateFunction._get_next_id()
175
+ _register_state_function(self)
176
+
177
+ # Infer states from transition matrix if not provided
178
+ if self.states is None and self.transitions is not None:
179
+ self.states = set(range(self.transitions.size))
180
+
181
+ @staticmethod
182
+ def _get_next_id() -> int:
183
+ """Get next unique ID."""
184
+ current = getattr(StateFunction, "_id_counter", 0)
185
+ StateFunction._id_counter = current + 1
186
+ return current
187
+
188
+ @property
189
+ def num_states(self) -> int | None:
190
+ """Number of valid states, if known."""
191
+ if self.states is not None:
192
+ return len(self.states)
193
+ if self.transitions is not None:
194
+ return self.transitions.size
195
+ return None
196
+
197
+ def __hash__(self) -> int:
198
+ """Hash based on unique ID."""
199
+ return hash(self._id)
200
+
201
+ def __repr__(self) -> str:
202
+ """String representation."""
203
+ parts = [f"StateFunction({self.name!r}"]
204
+ if self.transitions:
205
+ parts.append(f", transitions={self.transitions.size}x{self.transitions.size}")
206
+ if self.initial_state is not None:
207
+ parts.append(f", initial={self.initial_state}")
208
+ parts.append(")")
209
+ return "".join(parts)
210
+
211
+
212
+ # =============================================================================
213
+ # State Constraint Types
214
+ # =============================================================================
215
+
216
+
217
+ class StateConstraintType(Enum):
218
+ """Types of state constraints."""
219
+
220
+ ALWAYS_IN = auto() # State in range [min, max]
221
+ ALWAYS_EQUAL = auto() # State equals specific value
222
+ ALWAYS_CONSTANT = auto() # State doesn't change during interval
223
+ ALWAYS_NO_STATE = auto() # No state defined during interval
224
+
225
+
226
+ @dataclass
227
+ class StateConstraint:
228
+ """
229
+ Constraint on a state function during an interval.
230
+
231
+ Attributes:
232
+ state_func: The state function being constrained.
233
+ interval: The interval during which the constraint applies.
234
+ constraint_type: Type of constraint.
235
+ value: State value for ALWAYS_EQUAL.
236
+ min_value: Minimum state for ALWAYS_IN.
237
+ max_value: Maximum state for ALWAYS_IN.
238
+ is_start_aligned: Whether constraint starts exactly at interval start.
239
+ is_end_aligned: Whether constraint ends exactly at interval end.
240
+ """
241
+
242
+ state_func: StateFunction
243
+ interval: IntervalVar
244
+ constraint_type: StateConstraintType
245
+ value: int | None = None
246
+ min_value: int | None = None
247
+ max_value: int | None = None
248
+ is_start_aligned: bool = True
249
+ is_end_aligned: bool = True
250
+
251
+ def __repr__(self) -> str:
252
+ """String representation."""
253
+ interval_name = self.interval.name if self.interval else "?"
254
+ if self.constraint_type == StateConstraintType.ALWAYS_EQUAL:
255
+ return f"always_equal({self.state_func.name}, {interval_name}, {self.value})"
256
+ elif self.constraint_type == StateConstraintType.ALWAYS_IN:
257
+ return f"always_in({self.state_func.name}, {interval_name}, {self.min_value}, {self.max_value})"
258
+ elif self.constraint_type == StateConstraintType.ALWAYS_CONSTANT:
259
+ return f"always_constant({self.state_func.name}, {interval_name})"
260
+ elif self.constraint_type == StateConstraintType.ALWAYS_NO_STATE:
261
+ return f"always_no_state({self.state_func.name}, {interval_name})"
262
+ return f"StateConstraint({self.constraint_type})"
263
+
264
+
265
+ # =============================================================================
266
+ # State Constraint Functions
267
+ # =============================================================================
268
+
269
+
270
+ def always_equal(
271
+ state_func: StateFunction,
272
+ interval: IntervalVar,
273
+ value: int,
274
+ is_start_aligned: bool = True,
275
+ is_end_aligned: bool = True,
276
+ ) -> StateConstraint:
277
+ """
278
+ Constrain state function to equal a specific value during interval.
279
+
280
+ The state function must be equal to the specified value throughout
281
+ the execution of the interval.
282
+
283
+ Args:
284
+ state_func: The state function.
285
+ interval: The interval during which the constraint applies.
286
+ value: The required state value.
287
+ is_start_aligned: If True, state must equal value exactly at start.
288
+ is_end_aligned: If True, state must equal value exactly at end.
289
+
290
+ Returns:
291
+ A StateConstraint representing the always_equal constraint.
292
+
293
+ Example:
294
+ >>> machine = StateFunction(name="machine")
295
+ >>> # Machine must be in state 2 during task
296
+ >>> satisfy(always_equal(machine, task, 2))
297
+ """
298
+ from pycsp3_scheduling.variables.interval import IntervalVar
299
+
300
+ if not isinstance(state_func, StateFunction):
301
+ raise TypeError(
302
+ f"state_func must be a StateFunction, got {type(state_func).__name__}"
303
+ )
304
+ if not isinstance(interval, IntervalVar):
305
+ raise TypeError(
306
+ f"interval must be an IntervalVar, got {type(interval).__name__}"
307
+ )
308
+ if not isinstance(value, int):
309
+ raise TypeError(f"value must be an int, got {type(value).__name__}")
310
+
311
+ return StateConstraint(
312
+ state_func=state_func,
313
+ interval=interval,
314
+ constraint_type=StateConstraintType.ALWAYS_EQUAL,
315
+ value=value,
316
+ is_start_aligned=is_start_aligned,
317
+ is_end_aligned=is_end_aligned,
318
+ )
319
+
320
+
321
+ def always_in(
322
+ state_func: StateFunction,
323
+ interval: IntervalVar,
324
+ min_value: int,
325
+ max_value: int,
326
+ is_start_aligned: bool = True,
327
+ is_end_aligned: bool = True,
328
+ ) -> StateConstraint:
329
+ """
330
+ Constrain state function to be within a range during interval.
331
+
332
+ The state function must be within [min_value, max_value] throughout
333
+ the execution of the interval.
334
+
335
+ Args:
336
+ state_func: The state function.
337
+ interval: The interval during which the constraint applies.
338
+ min_value: Minimum allowed state value.
339
+ max_value: Maximum allowed state value.
340
+ is_start_aligned: If True, constraint applies exactly at start.
341
+ is_end_aligned: If True, constraint applies exactly at end.
342
+
343
+ Returns:
344
+ A StateConstraint representing the always_in constraint.
345
+
346
+ Example:
347
+ >>> machine = StateFunction(name="machine")
348
+ >>> # Machine must be in state 1, 2, or 3 during task
349
+ >>> satisfy(always_in(machine, task, 1, 3))
350
+ """
351
+ from pycsp3_scheduling.variables.interval import IntervalVar
352
+
353
+ if not isinstance(state_func, StateFunction):
354
+ raise TypeError(
355
+ f"state_func must be a StateFunction, got {type(state_func).__name__}"
356
+ )
357
+ if not isinstance(interval, IntervalVar):
358
+ raise TypeError(
359
+ f"interval must be an IntervalVar, got {type(interval).__name__}"
360
+ )
361
+ if not isinstance(min_value, int) or not isinstance(max_value, int):
362
+ raise TypeError("min_value and max_value must be integers")
363
+ if min_value > max_value:
364
+ raise ValueError(
365
+ f"min_value ({min_value}) cannot exceed max_value ({max_value})"
366
+ )
367
+
368
+ return StateConstraint(
369
+ state_func=state_func,
370
+ interval=interval,
371
+ constraint_type=StateConstraintType.ALWAYS_IN,
372
+ min_value=min_value,
373
+ max_value=max_value,
374
+ is_start_aligned=is_start_aligned,
375
+ is_end_aligned=is_end_aligned,
376
+ )
377
+
378
+
379
+ def always_constant(
380
+ state_func: StateFunction,
381
+ interval: IntervalVar,
382
+ is_start_aligned: bool = True,
383
+ is_end_aligned: bool = True,
384
+ ) -> StateConstraint:
385
+ """
386
+ Constrain state function to remain constant during interval.
387
+
388
+ The state function must not change its value throughout the
389
+ execution of the interval.
390
+
391
+ Args:
392
+ state_func: The state function.
393
+ interval: The interval during which the constraint applies.
394
+ is_start_aligned: If True, constant region starts exactly at start.
395
+ is_end_aligned: If True, constant region ends exactly at end.
396
+
397
+ Returns:
398
+ A StateConstraint representing the always_constant constraint.
399
+
400
+ Example:
401
+ >>> machine = StateFunction(name="machine")
402
+ >>> # Machine state cannot change during task
403
+ >>> satisfy(always_constant(machine, task))
404
+ """
405
+ from pycsp3_scheduling.variables.interval import IntervalVar
406
+
407
+ if not isinstance(state_func, StateFunction):
408
+ raise TypeError(
409
+ f"state_func must be a StateFunction, got {type(state_func).__name__}"
410
+ )
411
+ if not isinstance(interval, IntervalVar):
412
+ raise TypeError(
413
+ f"interval must be an IntervalVar, got {type(interval).__name__}"
414
+ )
415
+
416
+ return StateConstraint(
417
+ state_func=state_func,
418
+ interval=interval,
419
+ constraint_type=StateConstraintType.ALWAYS_CONSTANT,
420
+ is_start_aligned=is_start_aligned,
421
+ is_end_aligned=is_end_aligned,
422
+ )
423
+
424
+
425
+ def always_no_state(
426
+ state_func: StateFunction,
427
+ interval: IntervalVar,
428
+ is_start_aligned: bool = True,
429
+ is_end_aligned: bool = True,
430
+ ) -> StateConstraint:
431
+ """
432
+ Constrain state function to have no defined state during interval.
433
+
434
+ The state function must not be in any state throughout the
435
+ execution of the interval (the resource is "unused").
436
+
437
+ Args:
438
+ state_func: The state function.
439
+ interval: The interval during which the constraint applies.
440
+ is_start_aligned: If True, no-state region starts exactly at start.
441
+ is_end_aligned: If True, no-state region ends exactly at end.
442
+
443
+ Returns:
444
+ A StateConstraint representing the always_no_state constraint.
445
+
446
+ Example:
447
+ >>> machine = StateFunction(name="machine")
448
+ >>> # Machine must be unused during maintenance
449
+ >>> satisfy(always_no_state(machine, maintenance_interval))
450
+ """
451
+ from pycsp3_scheduling.variables.interval import IntervalVar
452
+
453
+ if not isinstance(state_func, StateFunction):
454
+ raise TypeError(
455
+ f"state_func must be a StateFunction, got {type(state_func).__name__}"
456
+ )
457
+ if not isinstance(interval, IntervalVar):
458
+ raise TypeError(
459
+ f"interval must be an IntervalVar, got {type(interval).__name__}"
460
+ )
461
+
462
+ return StateConstraint(
463
+ state_func=state_func,
464
+ interval=interval,
465
+ constraint_type=StateConstraintType.ALWAYS_NO_STATE,
466
+ is_start_aligned=is_start_aligned,
467
+ is_end_aligned=is_end_aligned,
468
+ )
469
+
470
+
471
+ # =============================================================================
472
+ # Registry for State Functions
473
+ # =============================================================================
474
+
475
+
476
+ _state_function_registry: list[StateFunction] = []
477
+
478
+
479
+ def _register_state_function(sf: StateFunction) -> None:
480
+ """Register a state function."""
481
+ if sf not in _state_function_registry:
482
+ _state_function_registry.append(sf)
483
+
484
+
485
+ def get_registered_state_functions() -> list[StateFunction]:
486
+ """Get all registered state functions."""
487
+ return list(_state_function_registry)
488
+
489
+
490
+ def clear_state_function_registry() -> None:
491
+ """Clear the state function registry."""
492
+ _state_function_registry.clear()
493
+ StateFunction._id_counter = 0
494
+ TransitionMatrix._id_counter = 0