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,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 = {}