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,1315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualization utilities for scheduling solutions.
|
|
3
|
+
|
|
4
|
+
This module provides visualization capabilities for constraint programming scheduling solutions
|
|
5
|
+
|
|
6
|
+
Requires matplotlib for rendering. Install with:
|
|
7
|
+
pip install matplotlib
|
|
8
|
+
|
|
9
|
+
Basic usage:
|
|
10
|
+
>>> from pycsp3_scheduling import visu, interval_value
|
|
11
|
+
>>> from pycsp3_scheduling.interop import IntervalValue
|
|
12
|
+
>>> if visu.is_visu_enabled():
|
|
13
|
+
... visu.timeline("My Schedule")
|
|
14
|
+
... visu.panel("Machine 1")
|
|
15
|
+
... # Using IntervalValue directly
|
|
16
|
+
... visu.interval(IntervalValue(start=0, length=10, name="Task A"), color=0)
|
|
17
|
+
... # Or from solved intervals
|
|
18
|
+
... val = interval_value(task)
|
|
19
|
+
... visu.interval(val, color=1)
|
|
20
|
+
... visu.show()
|
|
21
|
+
|
|
22
|
+
The visualization consists of:
|
|
23
|
+
- Timeline: The main figure containing panels
|
|
24
|
+
- Panel: A row in the timeline showing intervals, sequences, or functions
|
|
25
|
+
- Interval: A time-bounded task displayed as a colored rectangle
|
|
26
|
+
- Sequence: An ordered list of intervals with optional transitions
|
|
27
|
+
- Function: A step function (e.g., cumulative resource usage)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Callable, Sequence
|
|
37
|
+
|
|
38
|
+
from pycsp3_scheduling.variables import IntervalVar, SequenceVar
|
|
39
|
+
from pycsp3_scheduling.interop import IntervalValue
|
|
40
|
+
|
|
41
|
+
# Check if matplotlib is available
|
|
42
|
+
_MATPLOTLIB_AVAILABLE = False
|
|
43
|
+
try:
|
|
44
|
+
import matplotlib.pyplot as plt
|
|
45
|
+
import matplotlib.patches as mpatches
|
|
46
|
+
from matplotlib.axes import Axes
|
|
47
|
+
from matplotlib.figure import Figure
|
|
48
|
+
|
|
49
|
+
_MATPLOTLIB_AVAILABLE = True
|
|
50
|
+
except ImportError:
|
|
51
|
+
plt = None
|
|
52
|
+
mpatches = None
|
|
53
|
+
Axes = None
|
|
54
|
+
Figure = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# COLOR PALETTE
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
# Default color palette for intervals (similar to docplex)
|
|
62
|
+
# These are visually distinct colors suitable for Gantt charts
|
|
63
|
+
DEFAULT_COLORS = [
|
|
64
|
+
"#4E79A7", # Blue
|
|
65
|
+
"#F28E2B", # Orange
|
|
66
|
+
"#E15759", # Red
|
|
67
|
+
"#76B7B2", # Teal
|
|
68
|
+
"#59A14F", # Green
|
|
69
|
+
"#EDC948", # Yellow
|
|
70
|
+
"#B07AA1", # Purple
|
|
71
|
+
"#FF9DA7", # Pink
|
|
72
|
+
"#9C755F", # Brown
|
|
73
|
+
"#BAB0AC", # Gray
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# Color mapping: integer index -> color string
|
|
77
|
+
_color_map: dict[int, str] = {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_color(color: int | str | None, default_index: int = 0) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Get a color string from a color specification.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
color: Integer index, color name/hex string, or None for auto.
|
|
86
|
+
default_index: Default index when color is None.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A color string usable by matplotlib.
|
|
90
|
+
"""
|
|
91
|
+
if color is None:
|
|
92
|
+
color = default_index
|
|
93
|
+
|
|
94
|
+
if isinstance(color, int):
|
|
95
|
+
# Integer index: use palette with auto-allocation
|
|
96
|
+
if color not in _color_map:
|
|
97
|
+
palette_index = len(_color_map) % len(DEFAULT_COLORS)
|
|
98
|
+
_color_map[color] = DEFAULT_COLORS[palette_index]
|
|
99
|
+
return _color_map[color]
|
|
100
|
+
|
|
101
|
+
# String color: use directly
|
|
102
|
+
return str(color)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# =============================================================================
|
|
106
|
+
# GLOBAL STATE
|
|
107
|
+
# =============================================================================
|
|
108
|
+
|
|
109
|
+
# Current timeline and panel state
|
|
110
|
+
_current_timeline: Timeline | None = None
|
|
111
|
+
_current_panel: Panel | None = None
|
|
112
|
+
_naming_func: Callable[[str], str] | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# =============================================================================
|
|
116
|
+
# DATA CLASSES
|
|
117
|
+
# =============================================================================
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Segment:
|
|
122
|
+
"""
|
|
123
|
+
A segment in a step function.
|
|
124
|
+
|
|
125
|
+
Represents a constant value over a time interval [start, end).
|
|
126
|
+
For sloped segments, use value_start and value_end (not yet implemented).
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
start: Start time (inclusive).
|
|
130
|
+
end: End time (exclusive).
|
|
131
|
+
value: The constant value in this segment.
|
|
132
|
+
name: Optional label for the segment.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
start: int | float
|
|
136
|
+
end: int | float
|
|
137
|
+
value: int | float
|
|
138
|
+
name: str | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class IntervalData:
|
|
143
|
+
"""
|
|
144
|
+
Data for displaying an interval.
|
|
145
|
+
|
|
146
|
+
Attributes:
|
|
147
|
+
start: Start time.
|
|
148
|
+
end: End time.
|
|
149
|
+
name: Label for the interval.
|
|
150
|
+
color: Color specification (index or string).
|
|
151
|
+
height: Height of the bar (0.0 to 1.0).
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
start: int | float
|
|
155
|
+
end: int | float
|
|
156
|
+
name: str | None = None
|
|
157
|
+
color: int | str | None = None
|
|
158
|
+
height: float = 0.8
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class TransitionData:
|
|
163
|
+
"""
|
|
164
|
+
Data for displaying a transition between intervals.
|
|
165
|
+
|
|
166
|
+
Attributes:
|
|
167
|
+
start: Start time (end of previous interval).
|
|
168
|
+
end: End time (start of next interval).
|
|
169
|
+
name: Optional label.
|
|
170
|
+
color: Color for the transition marker.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
start: int | float
|
|
174
|
+
end: int | float
|
|
175
|
+
name: str | None = None
|
|
176
|
+
color: int | str | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class PauseData:
|
|
181
|
+
"""
|
|
182
|
+
Data for displaying a pause/inactive period.
|
|
183
|
+
|
|
184
|
+
Attributes:
|
|
185
|
+
start: Start time.
|
|
186
|
+
end: End time.
|
|
187
|
+
name: Optional label.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
start: int | float
|
|
191
|
+
end: int | float
|
|
192
|
+
name: str | None = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class Panel:
|
|
197
|
+
"""
|
|
198
|
+
A panel in the timeline displaying intervals, sequences, or functions.
|
|
199
|
+
|
|
200
|
+
Attributes:
|
|
201
|
+
name: Label for the panel (shown on y-axis).
|
|
202
|
+
intervals: List of intervals to display.
|
|
203
|
+
transitions: List of transitions between intervals.
|
|
204
|
+
pauses: List of pause periods.
|
|
205
|
+
segments: List of function segments.
|
|
206
|
+
panel_type: Type of panel ('interval', 'sequence', 'function').
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
name: str | None = None
|
|
210
|
+
intervals: list[IntervalData] = field(default_factory=list)
|
|
211
|
+
transitions: list[TransitionData] = field(default_factory=list)
|
|
212
|
+
pauses: list[PauseData] = field(default_factory=list)
|
|
213
|
+
segments: list[Segment] = field(default_factory=list)
|
|
214
|
+
panel_type: str = "interval"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class AnnotationData:
|
|
219
|
+
"""
|
|
220
|
+
Data for annotations (vertical lines, horizontal lines, text).
|
|
221
|
+
|
|
222
|
+
Attributes:
|
|
223
|
+
kind: Type of annotation ('vline', 'hline', 'text').
|
|
224
|
+
x: X coordinate (time).
|
|
225
|
+
y: Y coordinate (for text and hline).
|
|
226
|
+
value: Value for hline or text content.
|
|
227
|
+
color: Color for the annotation.
|
|
228
|
+
label: Optional label (for legend).
|
|
229
|
+
style: Line style ('solid', 'dashed', 'dotted').
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
kind: str
|
|
233
|
+
x: int | float | None = None
|
|
234
|
+
y: int | float | None = None
|
|
235
|
+
value: str | int | float | None = None
|
|
236
|
+
color: str | None = None
|
|
237
|
+
label: str | None = None
|
|
238
|
+
style: str = "dashed"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class LegendItem:
|
|
243
|
+
"""
|
|
244
|
+
Data for a legend entry.
|
|
245
|
+
|
|
246
|
+
Attributes:
|
|
247
|
+
label: Text label for the legend entry.
|
|
248
|
+
color: Color for the legend marker.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
label: str
|
|
252
|
+
color: int | str
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class Timeline:
|
|
257
|
+
"""
|
|
258
|
+
The main visualization figure.
|
|
259
|
+
|
|
260
|
+
Attributes:
|
|
261
|
+
title: Title for the figure.
|
|
262
|
+
origin: Start of the time axis.
|
|
263
|
+
horizon: End of the time axis (auto-computed if None).
|
|
264
|
+
panels: List of panels to display.
|
|
265
|
+
legend_items: List of legend entries.
|
|
266
|
+
annotations: List of global annotations.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
title: str | None = None
|
|
270
|
+
origin: int | float = 0
|
|
271
|
+
horizon: int | float | None = None
|
|
272
|
+
panels: list[Panel] = field(default_factory=list)
|
|
273
|
+
legend_items: list[LegendItem] = field(default_factory=list)
|
|
274
|
+
annotations: list[AnnotationData] = field(default_factory=list)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# =============================================================================
|
|
278
|
+
# PUBLIC API
|
|
279
|
+
# =============================================================================
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_visu_enabled() -> bool:
|
|
283
|
+
"""
|
|
284
|
+
Check if visualization is available.
|
|
285
|
+
|
|
286
|
+
Returns True if matplotlib is installed and can be used.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
True if visualization is enabled, False otherwise.
|
|
290
|
+
|
|
291
|
+
Example:
|
|
292
|
+
>>> if visu.is_visu_enabled():
|
|
293
|
+
... visu.timeline("Schedule")
|
|
294
|
+
... visu.show()
|
|
295
|
+
"""
|
|
296
|
+
return _MATPLOTLIB_AVAILABLE
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def timeline(
|
|
300
|
+
title: str | None = None,
|
|
301
|
+
origin: int | float = 0,
|
|
302
|
+
horizon: int | float | None = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Create a new timeline for visualization.
|
|
306
|
+
|
|
307
|
+
This creates a new figure and sets it as the current timeline.
|
|
308
|
+
Subsequent calls to panel(), interval(), etc. will add to this timeline.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
title: Title for the figure.
|
|
312
|
+
origin: Start of the time axis (default 0).
|
|
313
|
+
horizon: End of the time axis (auto-computed from content if None).
|
|
314
|
+
|
|
315
|
+
Example:
|
|
316
|
+
>>> visu.timeline("Job Shop Schedule", origin=0, horizon=100)
|
|
317
|
+
>>> visu.panel("Machine 1")
|
|
318
|
+
>>> visu.interval(IntervalValue(start=0, length=10, name="Task A"))
|
|
319
|
+
"""
|
|
320
|
+
global _current_timeline, _current_panel
|
|
321
|
+
_current_timeline = Timeline(title=title, origin=origin, horizon=horizon)
|
|
322
|
+
_current_panel = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def panel(name: str | None = None) -> None:
|
|
326
|
+
"""
|
|
327
|
+
Add a new panel to the current timeline.
|
|
328
|
+
|
|
329
|
+
A panel is a row in the visualization that can contain intervals,
|
|
330
|
+
sequences, or function segments. The panel type is automatically
|
|
331
|
+
determined by the content added to it.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
name: Label for the panel (displayed on the y-axis).
|
|
335
|
+
|
|
336
|
+
Example:
|
|
337
|
+
>>> visu.timeline("Schedule")
|
|
338
|
+
>>> visu.panel("Machine 1")
|
|
339
|
+
>>> visu.interval(IntervalValue(start=0, length=10, name="Task A"))
|
|
340
|
+
>>> visu.panel("Machine 2")
|
|
341
|
+
>>> visu.interval(IntervalValue(start=5, length=10, name="Task B"))
|
|
342
|
+
"""
|
|
343
|
+
global _current_panel
|
|
344
|
+
if _current_timeline is None:
|
|
345
|
+
timeline()
|
|
346
|
+
p = Panel(name=name)
|
|
347
|
+
_current_timeline.panels.append(p)
|
|
348
|
+
_current_panel = p
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def interval(
|
|
352
|
+
value: IntervalValue,
|
|
353
|
+
color: int | str | None = None,
|
|
354
|
+
height: float = 0.8,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""
|
|
357
|
+
Add an interval to the current panel.
|
|
358
|
+
|
|
359
|
+
Displays a colored rectangle representing a task or activity.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
value: An IntervalValue containing start, end, and optionally name.
|
|
363
|
+
color: Color as integer index or color string.
|
|
364
|
+
Integer indices are automatically mapped to palette colors.
|
|
365
|
+
height: Height of the bar (0.0 to 1.0, default 0.8).
|
|
366
|
+
|
|
367
|
+
Example:
|
|
368
|
+
>>> visu.panel("Machine 1")
|
|
369
|
+
>>> value = IntervalValue(start=0, length=10, name="Task A")
|
|
370
|
+
>>> visu.interval(value, color=0)
|
|
371
|
+
>>> # Or from a solved interval:
|
|
372
|
+
>>> val = interval_value(task)
|
|
373
|
+
>>> visu.interval(val, color=1)
|
|
374
|
+
"""
|
|
375
|
+
if _current_panel is None:
|
|
376
|
+
panel()
|
|
377
|
+
|
|
378
|
+
# Apply naming function if set
|
|
379
|
+
display_name = value.name
|
|
380
|
+
if _naming_func is not None and value.name is not None:
|
|
381
|
+
display_name = _naming_func(value.name)
|
|
382
|
+
|
|
383
|
+
_current_panel.intervals.append(
|
|
384
|
+
IntervalData(
|
|
385
|
+
start=value.start,
|
|
386
|
+
end=value.end,
|
|
387
|
+
name=display_name,
|
|
388
|
+
color=color,
|
|
389
|
+
height=height,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
_current_panel.panel_type = "interval"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def transition(
|
|
396
|
+
start: int | float,
|
|
397
|
+
end: int | float,
|
|
398
|
+
name: str | None = None,
|
|
399
|
+
color: int | str | None = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""
|
|
402
|
+
Add a transition marker between intervals.
|
|
403
|
+
|
|
404
|
+
Transitions represent setup times or changeover periods between tasks.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
start: Start time (typically end of previous interval).
|
|
408
|
+
end: End time (typically start of next interval).
|
|
409
|
+
name: Optional label.
|
|
410
|
+
color: Color for the transition marker.
|
|
411
|
+
|
|
412
|
+
Example:
|
|
413
|
+
>>> visu.panel("Machine")
|
|
414
|
+
>>> visu.interval(IntervalValue(start=0, length=10, name="Task A"), color=0)
|
|
415
|
+
>>> visu.transition(10, 12) # Setup time
|
|
416
|
+
>>> visu.interval(IntervalValue(start=12, length=13, name="Task B"), color=1)
|
|
417
|
+
"""
|
|
418
|
+
if _current_panel is None:
|
|
419
|
+
panel()
|
|
420
|
+
|
|
421
|
+
_current_panel.transitions.append(
|
|
422
|
+
TransitionData(start=start, end=end, name=name, color=color)
|
|
423
|
+
)
|
|
424
|
+
_current_panel.panel_type = "sequence"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def pause(
|
|
428
|
+
start: int | float,
|
|
429
|
+
end: int | float,
|
|
430
|
+
name: str | None = None,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""
|
|
433
|
+
Add a pause/inactive period to the current panel.
|
|
434
|
+
|
|
435
|
+
Pauses are displayed as hatched regions indicating unavailable time.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
start: Start time of the pause.
|
|
439
|
+
end: End time of the pause.
|
|
440
|
+
name: Optional label.
|
|
441
|
+
|
|
442
|
+
Example:
|
|
443
|
+
>>> visu.panel("Machine")
|
|
444
|
+
>>> visu.pause(50, 60, "Maintenance")
|
|
445
|
+
"""
|
|
446
|
+
if _current_panel is None:
|
|
447
|
+
panel()
|
|
448
|
+
|
|
449
|
+
_current_panel.pauses.append(PauseData(start=start, end=end, name=name))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def segment(
|
|
453
|
+
start: int | float,
|
|
454
|
+
end: int | float,
|
|
455
|
+
value: int | float,
|
|
456
|
+
name: str | None = None,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""
|
|
459
|
+
Add a segment to the current panel's function.
|
|
460
|
+
|
|
461
|
+
Segments define step functions for displaying cumulative resource usage
|
|
462
|
+
or other time-varying quantities.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
start: Start time of the segment.
|
|
466
|
+
end: End time of the segment.
|
|
467
|
+
value: Value during this segment.
|
|
468
|
+
name: Optional label.
|
|
469
|
+
|
|
470
|
+
Example:
|
|
471
|
+
>>> visu.panel("Resource Usage")
|
|
472
|
+
>>> visu.segment(0, 10, 2) # Usage is 2 from t=0 to t=10
|
|
473
|
+
>>> visu.segment(10, 15, 4) # Usage is 4 from t=10 to t=15
|
|
474
|
+
>>> visu.segment(15, 30, 1) # Usage is 1 from t=15 to t=30
|
|
475
|
+
"""
|
|
476
|
+
if _current_panel is None:
|
|
477
|
+
panel()
|
|
478
|
+
|
|
479
|
+
_current_panel.segments.append(
|
|
480
|
+
Segment(start=start, end=end, value=value, name=name)
|
|
481
|
+
)
|
|
482
|
+
_current_panel.panel_type = "function"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def function(
|
|
486
|
+
segments: Sequence[Segment | tuple[int | float, int | float, int | float]],
|
|
487
|
+
name: str | None = None,
|
|
488
|
+
color: int | str | None = None,
|
|
489
|
+
style: str = "area",
|
|
490
|
+
) -> None:
|
|
491
|
+
"""
|
|
492
|
+
Add a complete function to the current panel.
|
|
493
|
+
|
|
494
|
+
This is a convenience method for adding multiple segments at once.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
segments: List of Segment objects or (start, end, value) tuples.
|
|
498
|
+
name: Optional name for the function.
|
|
499
|
+
color: Color for the function area/line.
|
|
500
|
+
style: Display style - 'area' (filled), 'line', or 'step'.
|
|
501
|
+
|
|
502
|
+
Example:
|
|
503
|
+
>>> visu.panel("Cumulative")
|
|
504
|
+
>>> visu.function([
|
|
505
|
+
... (0, 10, 2),
|
|
506
|
+
... (10, 15, 4),
|
|
507
|
+
... (15, 30, 1),
|
|
508
|
+
... ], name="Usage", style="area")
|
|
509
|
+
"""
|
|
510
|
+
if _current_panel is None:
|
|
511
|
+
panel()
|
|
512
|
+
|
|
513
|
+
for seg in segments:
|
|
514
|
+
if isinstance(seg, Segment):
|
|
515
|
+
_current_panel.segments.append(seg)
|
|
516
|
+
else:
|
|
517
|
+
start, end, value = seg
|
|
518
|
+
_current_panel.segments.append(Segment(start=start, end=end, value=value))
|
|
519
|
+
|
|
520
|
+
_current_panel.panel_type = "function"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def sequence(
|
|
524
|
+
intervals: Sequence[IntervalData | tuple],
|
|
525
|
+
name: str | None = None,
|
|
526
|
+
transitions: Sequence[TransitionData | tuple] | None = None,
|
|
527
|
+
) -> None:
|
|
528
|
+
"""
|
|
529
|
+
Add a sequence of intervals to the current panel.
|
|
530
|
+
|
|
531
|
+
This is a convenience method for displaying ordered intervals
|
|
532
|
+
(e.g., tasks on a machine in execution order).
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
intervals: List of IntervalData or (start, end, name, color) tuples.
|
|
536
|
+
name: Name for the sequence (used as panel name if not set).
|
|
537
|
+
transitions: Optional list of transitions between intervals.
|
|
538
|
+
|
|
539
|
+
Example:
|
|
540
|
+
>>> visu.panel("Machine")
|
|
541
|
+
>>> visu.sequence([
|
|
542
|
+
... (0, 10, "Task A", 0),
|
|
543
|
+
... (12, 25, "Task B", 1),
|
|
544
|
+
... (25, 35, "Task C", 2),
|
|
545
|
+
... ])
|
|
546
|
+
"""
|
|
547
|
+
if _current_panel is None:
|
|
548
|
+
panel(name)
|
|
549
|
+
elif name is not None and _current_panel.name is None:
|
|
550
|
+
_current_panel.name = name
|
|
551
|
+
|
|
552
|
+
for intv in intervals:
|
|
553
|
+
if isinstance(intv, IntervalData):
|
|
554
|
+
_current_panel.intervals.append(intv)
|
|
555
|
+
else:
|
|
556
|
+
# Assume tuple: (start, end, name?, color?)
|
|
557
|
+
start, end = intv[0], intv[1]
|
|
558
|
+
intv_name = intv[2] if len(intv) > 2 else None
|
|
559
|
+
intv_color = intv[3] if len(intv) > 3 else None
|
|
560
|
+
_current_panel.intervals.append(
|
|
561
|
+
IntervalData(start=start, end=end, name=intv_name, color=intv_color)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
if transitions:
|
|
565
|
+
for trans in transitions:
|
|
566
|
+
if isinstance(trans, TransitionData):
|
|
567
|
+
_current_panel.transitions.append(trans)
|
|
568
|
+
else:
|
|
569
|
+
start, end = trans[0], trans[1]
|
|
570
|
+
trans_name = trans[2] if len(trans) > 2 else None
|
|
571
|
+
_current_panel.transitions.append(
|
|
572
|
+
TransitionData(start=start, end=end, name=trans_name)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
_current_panel.panel_type = "sequence"
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def naming(func: Callable[[str], str] | None) -> None:
|
|
579
|
+
"""
|
|
580
|
+
Set a naming function for interval labels.
|
|
581
|
+
|
|
582
|
+
The function is applied to all interval names before display.
|
|
583
|
+
Pass None to disable custom naming.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
func: A function that takes a name string and returns a formatted string,
|
|
587
|
+
or None to disable.
|
|
588
|
+
|
|
589
|
+
Example:
|
|
590
|
+
>>> # Show only task numbers
|
|
591
|
+
>>> visu.naming(lambda n: n.split("_")[-1])
|
|
592
|
+
>>> visu.interval(IntervalValue(start=0, length=10, name="task_1")) # Displays as "1"
|
|
593
|
+
"""
|
|
594
|
+
global _naming_func
|
|
595
|
+
_naming_func = func
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def show(block: bool = True) -> None:
|
|
599
|
+
"""
|
|
600
|
+
Render and display the current timeline.
|
|
601
|
+
|
|
602
|
+
This creates the matplotlib figure and displays it.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
block: If True, block execution until the figure is closed.
|
|
606
|
+
|
|
607
|
+
Example:
|
|
608
|
+
>>> visu.timeline("Schedule")
|
|
609
|
+
>>> visu.panel("Machine 1")
|
|
610
|
+
>>> visu.interval(IntervalValue(start=0, length=10, name="Task A"))
|
|
611
|
+
>>> visu.show()
|
|
612
|
+
"""
|
|
613
|
+
if not _MATPLOTLIB_AVAILABLE:
|
|
614
|
+
print("Visualization not available: matplotlib is not installed.")
|
|
615
|
+
print("Install with: pip install matplotlib")
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if _current_timeline is None:
|
|
619
|
+
print("No timeline to display. Call timeline() first.")
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
_render_timeline(_current_timeline)
|
|
623
|
+
plt.show(block=block)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def savefig(
|
|
627
|
+
filename: str,
|
|
628
|
+
dpi: int = 150,
|
|
629
|
+
bbox_inches: str = "tight",
|
|
630
|
+
transparent: bool = False,
|
|
631
|
+
) -> None:
|
|
632
|
+
"""
|
|
633
|
+
Save the current timeline to a file.
|
|
634
|
+
|
|
635
|
+
Renders the timeline and saves it to the specified file.
|
|
636
|
+
The format is determined by the file extension (png, pdf, svg, etc.).
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
filename: Output filename with extension (e.g., "schedule.png").
|
|
640
|
+
dpi: Resolution in dots per inch (default 150).
|
|
641
|
+
bbox_inches: Bounding box setting, "tight" removes extra whitespace.
|
|
642
|
+
transparent: If True, save with transparent background.
|
|
643
|
+
|
|
644
|
+
Example:
|
|
645
|
+
>>> visu.timeline("Schedule")
|
|
646
|
+
>>> visu.panel("Machine 1")
|
|
647
|
+
>>> visu.interval(IntervalValue(start=0, length=10, name="Task A"))
|
|
648
|
+
>>> visu.savefig("schedule.png")
|
|
649
|
+
>>> visu.savefig("schedule.pdf", dpi=300)
|
|
650
|
+
"""
|
|
651
|
+
if not _MATPLOTLIB_AVAILABLE:
|
|
652
|
+
print("Visualization not available: matplotlib is not installed.")
|
|
653
|
+
print("Install with: pip install matplotlib")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
if _current_timeline is None:
|
|
657
|
+
print("No timeline to save. Call timeline() first.")
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
fig = _render_timeline(_current_timeline)
|
|
661
|
+
fig.savefig(filename, dpi=dpi, bbox_inches=bbox_inches, transparent=transparent)
|
|
662
|
+
print(f"Saved visualization to: {filename}")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def close() -> None:
|
|
666
|
+
"""
|
|
667
|
+
Close the current figure and reset state.
|
|
668
|
+
|
|
669
|
+
Example:
|
|
670
|
+
>>> visu.show(block=False)
|
|
671
|
+
>>> # ... do other things ...
|
|
672
|
+
>>> visu.close()
|
|
673
|
+
"""
|
|
674
|
+
global _current_timeline, _current_panel
|
|
675
|
+
if _MATPLOTLIB_AVAILABLE:
|
|
676
|
+
plt.close()
|
|
677
|
+
_current_timeline = None
|
|
678
|
+
_current_panel = None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def legend(label: str, color: int | str) -> None:
|
|
682
|
+
"""
|
|
683
|
+
Add an entry to the legend.
|
|
684
|
+
|
|
685
|
+
The legend is displayed when show() or savefig() is called.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
label: Text label for the legend entry.
|
|
689
|
+
color: Color as integer index or color string.
|
|
690
|
+
|
|
691
|
+
Example:
|
|
692
|
+
>>> visu.timeline("Schedule")
|
|
693
|
+
>>> visu.legend("Task Type A", 0)
|
|
694
|
+
>>> visu.legend("Task Type B", 1)
|
|
695
|
+
>>> visu.legend("Maintenance", "gray")
|
|
696
|
+
>>> visu.show()
|
|
697
|
+
"""
|
|
698
|
+
if _current_timeline is None:
|
|
699
|
+
timeline()
|
|
700
|
+
|
|
701
|
+
_current_timeline.legend_items.append(LegendItem(label=label, color=color))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def vline(
|
|
705
|
+
x: int | float,
|
|
706
|
+
color: str = "red",
|
|
707
|
+
style: str = "dashed",
|
|
708
|
+
label: str | None = None,
|
|
709
|
+
) -> None:
|
|
710
|
+
"""
|
|
711
|
+
Add a vertical line annotation at a specific time.
|
|
712
|
+
|
|
713
|
+
The line spans all panels from top to bottom.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
x: Time position for the vertical line.
|
|
717
|
+
color: Color for the line (default "red").
|
|
718
|
+
style: Line style - 'solid', 'dashed', or 'dotted' (default "dashed").
|
|
719
|
+
label: Optional label to display above the line.
|
|
720
|
+
|
|
721
|
+
Example:
|
|
722
|
+
>>> visu.timeline("Schedule", horizon=100)
|
|
723
|
+
>>> visu.vline(50, color="red", label="Deadline")
|
|
724
|
+
>>> visu.vline(25, color="green", style="dotted", label="Milestone")
|
|
725
|
+
"""
|
|
726
|
+
if _current_timeline is None:
|
|
727
|
+
timeline()
|
|
728
|
+
|
|
729
|
+
_current_timeline.annotations.append(
|
|
730
|
+
AnnotationData(kind="vline", x=x, color=color, style=style, label=label)
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def hline(
|
|
735
|
+
y: int | float,
|
|
736
|
+
color: str = "red",
|
|
737
|
+
style: str = "dashed",
|
|
738
|
+
label: str | None = None,
|
|
739
|
+
panel_name: str | None = None,
|
|
740
|
+
) -> None:
|
|
741
|
+
"""
|
|
742
|
+
Add a horizontal line annotation at a specific value.
|
|
743
|
+
|
|
744
|
+
Useful for showing capacity limits on function panels.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
y: Y-axis value for the horizontal line.
|
|
748
|
+
color: Color for the line (default "red").
|
|
749
|
+
style: Line style - 'solid', 'dashed', or 'dotted' (default "dashed").
|
|
750
|
+
label: Optional label to display at the line.
|
|
751
|
+
panel_name: Panel to add the line to (current panel if None).
|
|
752
|
+
|
|
753
|
+
Example:
|
|
754
|
+
>>> visu.panel("Resource Usage")
|
|
755
|
+
>>> visu.segment(0, 10, 3)
|
|
756
|
+
>>> visu.hline(5, color="red", label="Capacity")
|
|
757
|
+
"""
|
|
758
|
+
if _current_panel is None:
|
|
759
|
+
panel()
|
|
760
|
+
|
|
761
|
+
# Store in current panel's annotations (we'll add this to Panel)
|
|
762
|
+
_current_timeline.annotations.append(
|
|
763
|
+
AnnotationData(
|
|
764
|
+
kind="hline",
|
|
765
|
+
y=y,
|
|
766
|
+
color=color,
|
|
767
|
+
style=style,
|
|
768
|
+
label=label,
|
|
769
|
+
value=panel_name or _current_panel.name,
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def annotate(
|
|
775
|
+
x: int | float,
|
|
776
|
+
text: str,
|
|
777
|
+
y: float = 0.95,
|
|
778
|
+
color: str = "black",
|
|
779
|
+
fontsize: int = 9,
|
|
780
|
+
) -> None:
|
|
781
|
+
"""
|
|
782
|
+
Add a text annotation at a specific time position.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
x: Time position for the annotation.
|
|
786
|
+
text: Text content to display.
|
|
787
|
+
y: Vertical position (0.0 to 1.0, default 0.95 = near top).
|
|
788
|
+
color: Text color (default "black").
|
|
789
|
+
fontsize: Font size (default 9).
|
|
790
|
+
|
|
791
|
+
Example:
|
|
792
|
+
>>> visu.timeline("Schedule")
|
|
793
|
+
>>> visu.annotate(50, "Important event", color="red")
|
|
794
|
+
"""
|
|
795
|
+
if _current_timeline is None:
|
|
796
|
+
timeline()
|
|
797
|
+
|
|
798
|
+
_current_timeline.annotations.append(
|
|
799
|
+
AnnotationData(kind="text", x=x, y=y, value=text, color=color)
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# =============================================================================
|
|
804
|
+
# HIGH-LEVEL CONVENIENCE FUNCTIONS
|
|
805
|
+
# =============================================================================
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def show_interval(
|
|
809
|
+
iv: IntervalVar,
|
|
810
|
+
value: IntervalValue | None = None,
|
|
811
|
+
panel_name: str | None = None,
|
|
812
|
+
color: int | str | None = None,
|
|
813
|
+
) -> None:
|
|
814
|
+
"""
|
|
815
|
+
Display a single interval variable.
|
|
816
|
+
|
|
817
|
+
If value is provided, uses the solved values. Otherwise, displays the bounds.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
iv: The IntervalVar to display.
|
|
821
|
+
value: Solved IntervalValue from interval_value().
|
|
822
|
+
panel_name: Name for the panel (defaults to interval name).
|
|
823
|
+
color: Color for the interval.
|
|
824
|
+
|
|
825
|
+
Example:
|
|
826
|
+
>>> task = IntervalVar(size=10, name="task")
|
|
827
|
+
>>> # After solving:
|
|
828
|
+
>>> vals = interval_value(task)
|
|
829
|
+
>>> visu.show_interval(task, vals)
|
|
830
|
+
"""
|
|
831
|
+
from pycsp3_scheduling.interop import IntervalValue as IV
|
|
832
|
+
|
|
833
|
+
if panel_name is None:
|
|
834
|
+
panel_name = iv.name or "Interval"
|
|
835
|
+
|
|
836
|
+
if _current_panel is None or _current_panel.name != panel_name:
|
|
837
|
+
panel(panel_name)
|
|
838
|
+
|
|
839
|
+
if value is not None:
|
|
840
|
+
# Use solved values
|
|
841
|
+
interval(value, color=color)
|
|
842
|
+
else:
|
|
843
|
+
# Use bounds - create IntervalValue from bounds
|
|
844
|
+
iv_bounds = IV(
|
|
845
|
+
start=iv.start_min,
|
|
846
|
+
length=iv.length_min,
|
|
847
|
+
name=iv.name,
|
|
848
|
+
)
|
|
849
|
+
interval(iv_bounds, color=color)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def show_sequence(
|
|
853
|
+
seq: SequenceVar,
|
|
854
|
+
values: Sequence[IntervalValue] | None = None,
|
|
855
|
+
panel_name: str | None = None,
|
|
856
|
+
) -> None:
|
|
857
|
+
"""
|
|
858
|
+
Display a sequence variable with its intervals.
|
|
859
|
+
|
|
860
|
+
Args:
|
|
861
|
+
seq: The SequenceVar to display.
|
|
862
|
+
values: List of solved IntervalValue for each interval.
|
|
863
|
+
panel_name: Name for the panel (defaults to sequence name).
|
|
864
|
+
|
|
865
|
+
Example:
|
|
866
|
+
>>> machine = SequenceVar(intervals=tasks, name="machine")
|
|
867
|
+
>>> # After solving:
|
|
868
|
+
>>> vals = [interval_value(t) for t in tasks]
|
|
869
|
+
>>> visu.show_sequence(machine, vals)
|
|
870
|
+
"""
|
|
871
|
+
from pycsp3_scheduling.interop import IntervalValue as IV
|
|
872
|
+
|
|
873
|
+
if panel_name is None:
|
|
874
|
+
panel_name = seq.name or "Sequence"
|
|
875
|
+
|
|
876
|
+
panel(panel_name)
|
|
877
|
+
|
|
878
|
+
intervals_data: list[tuple[IntervalValue, int | str]] = []
|
|
879
|
+
for i, intv in enumerate(seq.intervals):
|
|
880
|
+
if values is not None and i < len(values):
|
|
881
|
+
val = values[i]
|
|
882
|
+
if val is None:
|
|
883
|
+
continue # Absent interval
|
|
884
|
+
iv = val
|
|
885
|
+
else:
|
|
886
|
+
# Use bounds
|
|
887
|
+
iv = IV(start=intv.start_min, length=intv.length_min, name=intv.name)
|
|
888
|
+
|
|
889
|
+
color = seq.types[i] if seq.types else i
|
|
890
|
+
intervals_data.append((iv, color))
|
|
891
|
+
|
|
892
|
+
# Sort by start time
|
|
893
|
+
intervals_data.sort(key=lambda x: x[0].start)
|
|
894
|
+
|
|
895
|
+
for iv, color in intervals_data:
|
|
896
|
+
interval(iv, color=color)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# =============================================================================
|
|
900
|
+
# RENDERING
|
|
901
|
+
# =============================================================================
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _render_timeline(tl: Timeline) -> Figure:
|
|
905
|
+
"""Render a timeline to a matplotlib figure."""
|
|
906
|
+
if not _MATPLOTLIB_AVAILABLE:
|
|
907
|
+
raise RuntimeError("matplotlib is required for visualization")
|
|
908
|
+
|
|
909
|
+
# Compute horizon if not set
|
|
910
|
+
horizon = tl.horizon
|
|
911
|
+
if horizon is None:
|
|
912
|
+
horizon = tl.origin
|
|
913
|
+
for p in tl.panels:
|
|
914
|
+
for intv in p.intervals:
|
|
915
|
+
horizon = max(horizon, intv.end)
|
|
916
|
+
for seg in p.segments:
|
|
917
|
+
horizon = max(horizon, seg.end)
|
|
918
|
+
for pause_data in p.pauses:
|
|
919
|
+
horizon = max(horizon, pause_data.end)
|
|
920
|
+
horizon = max(horizon, tl.origin + 10) # Minimum width
|
|
921
|
+
|
|
922
|
+
num_panels = len(tl.panels)
|
|
923
|
+
if num_panels == 0:
|
|
924
|
+
num_panels = 1
|
|
925
|
+
|
|
926
|
+
# Create figure with subplots for each panel
|
|
927
|
+
fig_height = max(2, 1.5 * num_panels)
|
|
928
|
+
fig, axes = plt.subplots(
|
|
929
|
+
num_panels, 1, figsize=(12, fig_height), squeeze=False, sharex=True
|
|
930
|
+
)
|
|
931
|
+
axes = axes.flatten()
|
|
932
|
+
|
|
933
|
+
# Add title
|
|
934
|
+
if tl.title:
|
|
935
|
+
fig.suptitle(tl.title, fontsize=14, fontweight="bold")
|
|
936
|
+
|
|
937
|
+
# Build panel name to axes mapping
|
|
938
|
+
panel_axes = {}
|
|
939
|
+
for i, p in enumerate(tl.panels):
|
|
940
|
+
if p.name:
|
|
941
|
+
panel_axes[p.name] = axes[i]
|
|
942
|
+
|
|
943
|
+
# Render each panel
|
|
944
|
+
for i, p in enumerate(tl.panels):
|
|
945
|
+
ax = axes[i]
|
|
946
|
+
_render_panel(ax, p, tl.origin, horizon, i)
|
|
947
|
+
|
|
948
|
+
# Render global annotations
|
|
949
|
+
linestyle_map = {"solid": "-", "dashed": "--", "dotted": ":"}
|
|
950
|
+
for ann in tl.annotations:
|
|
951
|
+
if ann.kind == "vline":
|
|
952
|
+
# Draw vertical line on all panels
|
|
953
|
+
ls = linestyle_map.get(ann.style, "--")
|
|
954
|
+
for ax in axes:
|
|
955
|
+
ax.axvline(ann.x, color=ann.color, linestyle=ls, linewidth=1.5, alpha=0.8)
|
|
956
|
+
# Add label on top panel
|
|
957
|
+
if ann.label:
|
|
958
|
+
axes[0].text(
|
|
959
|
+
ann.x, 1.02, ann.label,
|
|
960
|
+
transform=axes[0].get_xaxis_transform(),
|
|
961
|
+
ha="center", va="bottom", fontsize=8, color=ann.color
|
|
962
|
+
)
|
|
963
|
+
elif ann.kind == "hline":
|
|
964
|
+
# Draw horizontal line on specified panel
|
|
965
|
+
target_ax = panel_axes.get(ann.value, axes[-1])
|
|
966
|
+
ls = linestyle_map.get(ann.style, "--")
|
|
967
|
+
target_ax.axhline(ann.y, color=ann.color, linestyle=ls, linewidth=1.5, alpha=0.8)
|
|
968
|
+
if ann.label:
|
|
969
|
+
target_ax.text(
|
|
970
|
+
horizon, ann.y, f" {ann.label}",
|
|
971
|
+
ha="left", va="center", fontsize=8, color=ann.color
|
|
972
|
+
)
|
|
973
|
+
elif ann.kind == "text":
|
|
974
|
+
# Draw text annotation on top panel
|
|
975
|
+
axes[0].text(
|
|
976
|
+
ann.x, ann.y, str(ann.value),
|
|
977
|
+
transform=axes[0].get_xaxis_transform(),
|
|
978
|
+
ha="center", va="top", fontsize=9, color=ann.color
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
# Set x-axis limits
|
|
982
|
+
for ax in axes:
|
|
983
|
+
ax.set_xlim(tl.origin, horizon)
|
|
984
|
+
|
|
985
|
+
# Add x-axis label to bottom panel
|
|
986
|
+
axes[-1].set_xlabel("Time")
|
|
987
|
+
|
|
988
|
+
# Add legend if items exist
|
|
989
|
+
if tl.legend_items:
|
|
990
|
+
handles = []
|
|
991
|
+
for item in tl.legend_items:
|
|
992
|
+
color = _get_color(item.color, 0)
|
|
993
|
+
patch = mpatches.Patch(color=color, label=item.label)
|
|
994
|
+
handles.append(patch)
|
|
995
|
+
fig.legend(
|
|
996
|
+
handles=handles,
|
|
997
|
+
loc="upper right",
|
|
998
|
+
bbox_to_anchor=(0.99, 0.99),
|
|
999
|
+
fontsize=9,
|
|
1000
|
+
framealpha=0.9,
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
plt.tight_layout()
|
|
1004
|
+
return fig
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _render_panel(
|
|
1008
|
+
ax: Axes,
|
|
1009
|
+
panel: Panel,
|
|
1010
|
+
origin: int | float,
|
|
1011
|
+
horizon: int | float,
|
|
1012
|
+
panel_index: int,
|
|
1013
|
+
) -> None:
|
|
1014
|
+
"""Render a single panel."""
|
|
1015
|
+
if panel.panel_type == "function":
|
|
1016
|
+
_render_function_panel(ax, panel, origin, horizon)
|
|
1017
|
+
else:
|
|
1018
|
+
_render_interval_panel(ax, panel, panel_index, origin, horizon)
|
|
1019
|
+
|
|
1020
|
+
# Set panel name as y-label
|
|
1021
|
+
if panel.name:
|
|
1022
|
+
ax.set_ylabel(panel.name, fontsize=10)
|
|
1023
|
+
|
|
1024
|
+
# Remove y-ticks for interval panels
|
|
1025
|
+
if panel.panel_type != "function":
|
|
1026
|
+
ax.set_yticks([])
|
|
1027
|
+
|
|
1028
|
+
# Add grid
|
|
1029
|
+
ax.grid(axis="x", linestyle="--", alpha=0.3)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _render_interval_panel(
|
|
1033
|
+
ax: Axes, panel: Panel, panel_index: int, origin: int | float, horizon: int | float
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Render an interval/sequence panel."""
|
|
1036
|
+
ax.set_ylim(0, 1)
|
|
1037
|
+
|
|
1038
|
+
# Render pauses first (background)
|
|
1039
|
+
for pause_data in panel.pauses:
|
|
1040
|
+
ax.axvspan(
|
|
1041
|
+
pause_data.start,
|
|
1042
|
+
pause_data.end,
|
|
1043
|
+
facecolor="gray",
|
|
1044
|
+
alpha=0.2,
|
|
1045
|
+
hatch="//",
|
|
1046
|
+
edgecolor="gray",
|
|
1047
|
+
)
|
|
1048
|
+
if pause_data.name:
|
|
1049
|
+
mid = (pause_data.start + pause_data.end) / 2
|
|
1050
|
+
ax.text(mid, 0.9, pause_data.name, ha="center", va="top", fontsize=8, alpha=0.7)
|
|
1051
|
+
|
|
1052
|
+
# Render transitions
|
|
1053
|
+
for trans in panel.transitions:
|
|
1054
|
+
color = _get_color(trans.color, panel_index)
|
|
1055
|
+
ax.axvspan(
|
|
1056
|
+
trans.start,
|
|
1057
|
+
trans.end,
|
|
1058
|
+
facecolor=color,
|
|
1059
|
+
alpha=0.3,
|
|
1060
|
+
hatch="\\\\",
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
# Calculate timeline span for text fitting
|
|
1064
|
+
timeline_span = horizon - origin
|
|
1065
|
+
|
|
1066
|
+
# Render intervals
|
|
1067
|
+
for i, intv in enumerate(panel.intervals):
|
|
1068
|
+
color = _get_color(intv.color, i)
|
|
1069
|
+
y_center = 0.5
|
|
1070
|
+
y_bottom = y_center - intv.height / 2
|
|
1071
|
+
|
|
1072
|
+
# Draw rectangle
|
|
1073
|
+
rect = mpatches.FancyBboxPatch(
|
|
1074
|
+
(intv.start, y_bottom),
|
|
1075
|
+
intv.end - intv.start,
|
|
1076
|
+
intv.height,
|
|
1077
|
+
boxstyle="round,pad=0.02,rounding_size=0.1",
|
|
1078
|
+
facecolor=color,
|
|
1079
|
+
edgecolor="black",
|
|
1080
|
+
linewidth=1,
|
|
1081
|
+
)
|
|
1082
|
+
ax.add_patch(rect)
|
|
1083
|
+
|
|
1084
|
+
# Add label with overflow handling
|
|
1085
|
+
if intv.name:
|
|
1086
|
+
mid_x = (intv.start + intv.end) / 2
|
|
1087
|
+
interval_width = intv.end - intv.start
|
|
1088
|
+
# Determine text color based on background brightness
|
|
1089
|
+
text_color = "white" if _is_dark_color(color) else "black"
|
|
1090
|
+
|
|
1091
|
+
# Fit text to interval width, accounting for timeline scale
|
|
1092
|
+
display_name = _fit_text_to_width(intv.name, interval_width, horizon=timeline_span)
|
|
1093
|
+
|
|
1094
|
+
if display_name:
|
|
1095
|
+
ax.text(
|
|
1096
|
+
mid_x,
|
|
1097
|
+
y_center,
|
|
1098
|
+
display_name,
|
|
1099
|
+
ha="center",
|
|
1100
|
+
va="center",
|
|
1101
|
+
fontsize=9,
|
|
1102
|
+
color=text_color,
|
|
1103
|
+
fontweight="bold",
|
|
1104
|
+
clip_on=True,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _render_function_panel(
|
|
1109
|
+
ax: Axes,
|
|
1110
|
+
panel: Panel,
|
|
1111
|
+
origin: int | float,
|
|
1112
|
+
horizon: int | float,
|
|
1113
|
+
) -> None:
|
|
1114
|
+
"""Render a function panel with step function."""
|
|
1115
|
+
if not panel.segments:
|
|
1116
|
+
ax.set_ylim(0, 1)
|
|
1117
|
+
return
|
|
1118
|
+
|
|
1119
|
+
# Sort segments by start time
|
|
1120
|
+
segments = sorted(panel.segments, key=lambda s: s.start)
|
|
1121
|
+
|
|
1122
|
+
# Build step function data
|
|
1123
|
+
times = [origin]
|
|
1124
|
+
values = [0]
|
|
1125
|
+
|
|
1126
|
+
for seg in segments:
|
|
1127
|
+
# Add step up at start
|
|
1128
|
+
times.extend([seg.start, seg.start])
|
|
1129
|
+
values.extend([values[-1], seg.value])
|
|
1130
|
+
# Add step down at end
|
|
1131
|
+
times.extend([seg.end, seg.end])
|
|
1132
|
+
values.extend([seg.value, 0])
|
|
1133
|
+
|
|
1134
|
+
times.append(horizon)
|
|
1135
|
+
values.append(0)
|
|
1136
|
+
|
|
1137
|
+
# Draw filled area
|
|
1138
|
+
ax.fill_between(times, values, alpha=0.4, color=DEFAULT_COLORS[0], step="post")
|
|
1139
|
+
ax.step(times, values, where="post", color=DEFAULT_COLORS[0], linewidth=2)
|
|
1140
|
+
|
|
1141
|
+
# Set y-axis limits with some padding
|
|
1142
|
+
max_value = max(seg.value for seg in segments) if segments else 1
|
|
1143
|
+
ax.set_ylim(0, max_value * 1.1)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _fit_text_to_width(
|
|
1147
|
+
text: str, width: float, char_width: float | None = None, horizon: float = 100.0
|
|
1148
|
+
) -> str | None:
|
|
1149
|
+
"""
|
|
1150
|
+
Fit text to a given width, truncating with ellipsis if needed.
|
|
1151
|
+
|
|
1152
|
+
Args:
|
|
1153
|
+
text: The text to fit.
|
|
1154
|
+
width: Available width in data units.
|
|
1155
|
+
char_width: Estimated width per character in data units. If None, computed
|
|
1156
|
+
based on the horizon (figure spans ~12 inches, font ~0.06 inches/char).
|
|
1157
|
+
horizon: The total timeline span for scaling calculations.
|
|
1158
|
+
|
|
1159
|
+
Returns:
|
|
1160
|
+
The fitted text, possibly truncated with "...", or None if too small.
|
|
1161
|
+
"""
|
|
1162
|
+
if char_width is None:
|
|
1163
|
+
# At fontsize 9, character width is roughly 0.06 inches
|
|
1164
|
+
# Figure width is 12 inches, so chars that fit = 12 / 0.06 = 200
|
|
1165
|
+
# If horizon=100, then char_width = 100 / 200 = 0.5 data units per char
|
|
1166
|
+
# Formula: char_width = horizon * 0.06 / 12 = horizon * 0.005
|
|
1167
|
+
char_width = max(0.1, horizon * 0.005)
|
|
1168
|
+
|
|
1169
|
+
# Estimate how many characters fit
|
|
1170
|
+
max_chars = int(width / char_width)
|
|
1171
|
+
|
|
1172
|
+
if max_chars < 1:
|
|
1173
|
+
# Too small to show anything
|
|
1174
|
+
return None
|
|
1175
|
+
elif max_chars < 3:
|
|
1176
|
+
# Very small: show first character only
|
|
1177
|
+
return text[0] if len(text) >= 1 else text
|
|
1178
|
+
elif len(text) <= max_chars:
|
|
1179
|
+
# Text fits completely
|
|
1180
|
+
return text
|
|
1181
|
+
else:
|
|
1182
|
+
# Truncate with ellipsis
|
|
1183
|
+
return text[: max_chars - 2] + ".."
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _is_dark_color(color: str) -> bool:
|
|
1187
|
+
"""
|
|
1188
|
+
Determine if a color is dark (for choosing text color).
|
|
1189
|
+
|
|
1190
|
+
Uses a simple luminance calculation.
|
|
1191
|
+
"""
|
|
1192
|
+
import matplotlib.colors as mcolors
|
|
1193
|
+
|
|
1194
|
+
try:
|
|
1195
|
+
rgb = mcolors.to_rgb(color)
|
|
1196
|
+
# Calculate relative luminance
|
|
1197
|
+
luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
|
|
1198
|
+
return luminance < 0.5
|
|
1199
|
+
except (ValueError, KeyError):
|
|
1200
|
+
return False
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
# =============================================================================
|
|
1204
|
+
# SUBPROCESS-SAFE VISUALIZATION
|
|
1205
|
+
# =============================================================================
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def savefig_safe(
|
|
1209
|
+
filename: str,
|
|
1210
|
+
visu_data: dict,
|
|
1211
|
+
title: str = "Schedule",
|
|
1212
|
+
horizon: int | float | None = None,
|
|
1213
|
+
legends: list[tuple[str, int | str]] | None = None,
|
|
1214
|
+
vlines: list[tuple[int | float, str, str, str | None]] | None = None,
|
|
1215
|
+
) -> None:
|
|
1216
|
+
"""
|
|
1217
|
+
Save visualization in a subprocess to avoid pycsp3 operator conflicts.
|
|
1218
|
+
|
|
1219
|
+
This function runs matplotlib in a separate Python process, which
|
|
1220
|
+
avoids conflicts with pycsp3's operator monkey-patching.
|
|
1221
|
+
|
|
1222
|
+
Args:
|
|
1223
|
+
filename: Output filename with extension (e.g., "schedule.png").
|
|
1224
|
+
visu_data: Dictionary with visualization data containing:
|
|
1225
|
+
- "panels": List of panel dicts with "name" and "intervals"
|
|
1226
|
+
where intervals are lists of (start, end, label, color)
|
|
1227
|
+
title: Title for the figure.
|
|
1228
|
+
horizon: End of the time axis (auto-computed if None).
|
|
1229
|
+
legends: Optional list of (label, color) tuples for the legend.
|
|
1230
|
+
vlines: Optional list of (x, color, style, label) tuples for vertical lines.
|
|
1231
|
+
|
|
1232
|
+
Example:
|
|
1233
|
+
>>> # Extract data from solved model
|
|
1234
|
+
>>> visu_data = {
|
|
1235
|
+
... "panels": [
|
|
1236
|
+
... {"name": "Machine 1", "intervals": [(0, 10, "Task A", 0), (15, 25, "Task B", 1)]},
|
|
1237
|
+
... {"name": "Machine 2", "intervals": [(5, 20, "Task C", 2)]},
|
|
1238
|
+
... ]
|
|
1239
|
+
... }
|
|
1240
|
+
>>> visu.savefig_safe("schedule.png", visu_data, title="Job Shop Schedule")
|
|
1241
|
+
"""
|
|
1242
|
+
import subprocess
|
|
1243
|
+
import json
|
|
1244
|
+
|
|
1245
|
+
# Build the visualization script
|
|
1246
|
+
visu_script = f'''
|
|
1247
|
+
import json
|
|
1248
|
+
import matplotlib
|
|
1249
|
+
matplotlib.use('Agg')
|
|
1250
|
+
from pycsp3_scheduling import visu
|
|
1251
|
+
|
|
1252
|
+
visu_data = {json.dumps(visu_data)}
|
|
1253
|
+
title = {json.dumps(title)}
|
|
1254
|
+
horizon = {json.dumps(horizon)}
|
|
1255
|
+
legends = {json.dumps(legends)}
|
|
1256
|
+
vlines = {json.dumps(vlines)}
|
|
1257
|
+
filename = {json.dumps(filename)}
|
|
1258
|
+
|
|
1259
|
+
# Compute horizon if not provided
|
|
1260
|
+
if horizon is None:
|
|
1261
|
+
horizon = 0
|
|
1262
|
+
for panel_data in visu_data.get("panels", []):
|
|
1263
|
+
for intv in panel_data.get("intervals", []):
|
|
1264
|
+
horizon = max(horizon, intv[1])
|
|
1265
|
+
horizon = max(horizon, 10) + 10
|
|
1266
|
+
|
|
1267
|
+
visu.reset()
|
|
1268
|
+
visu.timeline(title, origin=0, horizon=horizon)
|
|
1269
|
+
|
|
1270
|
+
# Add legends
|
|
1271
|
+
if legends:
|
|
1272
|
+
for label, color in legends:
|
|
1273
|
+
visu.legend(label, color)
|
|
1274
|
+
|
|
1275
|
+
# Add vertical lines
|
|
1276
|
+
if vlines:
|
|
1277
|
+
for x, color, style, label in vlines:
|
|
1278
|
+
visu.vline(x, color=color, style=style, label=label)
|
|
1279
|
+
|
|
1280
|
+
# Add panels
|
|
1281
|
+
from pycsp3_scheduling.interop import IntervalValue
|
|
1282
|
+
for panel_data in visu_data.get("panels", []):
|
|
1283
|
+
visu.panel(panel_data.get("name"))
|
|
1284
|
+
for intv in panel_data.get("intervals", []):
|
|
1285
|
+
start, end = intv[0], intv[1]
|
|
1286
|
+
name = intv[2] if len(intv) > 2 else None
|
|
1287
|
+
color = intv[3] if len(intv) > 3 else None
|
|
1288
|
+
visu.interval(IntervalValue(start=start, length=end-start, name=name), color=color)
|
|
1289
|
+
|
|
1290
|
+
visu.savefig(filename)
|
|
1291
|
+
'''
|
|
1292
|
+
result = subprocess.run(["python", "-c", visu_script], capture_output=True, text=True)
|
|
1293
|
+
if result.returncode != 0:
|
|
1294
|
+
print(f"Visualization error: {result.stderr}")
|
|
1295
|
+
else:
|
|
1296
|
+
if result.stdout:
|
|
1297
|
+
print(result.stdout, end="")
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
# =============================================================================
|
|
1301
|
+
# CLEANUP
|
|
1302
|
+
# =============================================================================
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def reset() -> None:
|
|
1306
|
+
"""
|
|
1307
|
+
Reset all visualization state.
|
|
1308
|
+
|
|
1309
|
+
Clears the current timeline, panel, and color mappings.
|
|
1310
|
+
"""
|
|
1311
|
+
global _current_timeline, _current_panel, _naming_func, _color_map
|
|
1312
|
+
_current_timeline = None
|
|
1313
|
+
_current_panel = None
|
|
1314
|
+
_naming_func = None
|
|
1315
|
+
_color_map = {}
|