Source code for moptipy.examples.jssp.plot_gantt_chart

"""Plot a Gantt chart into one figure."""
from typing import Callable, Final, Iterable

from matplotlib.artist import Artist  # type: ignore
from matplotlib.axes import Axes  # type: ignore
from matplotlib.figure import Figure  # type: ignore
from matplotlib.lines import Line2D  # type: ignore
from matplotlib.patches import Rectangle  # type: ignore
from matplotlib.text import Text  # type: ignore
from matplotlib.ticker import MaxNLocator  # type: ignore
from pycommons.types import type_error

import moptipy.utils.plot_defaults as pd
import moptipy.utils.plot_utils as pu
from moptipy.evaluation.axis_ranger import AxisRanger
from moptipy.examples.jssp.gantt import Gantt
from moptipy.examples.jssp.makespan import makespan
from moptipy.utils.lang import Lang


[docs] def marker_lb(x: Gantt) -> tuple[str, int | float]: """ Compute the marker for the lower bound. :param x: the Gantt chart :return: the lower bound marker """ return Lang.current()["lower_bound_short"], \ x.instance.makespan_lower_bound
[docs] def marker_makespan(x: Gantt) -> tuple[str, int | float]: """ Compute the marker for the makespan. :param x: the Gantt chart :return: the makespan marker """ return Lang.current()["makespan"], makespan(x)
#: the color for markers at the left end __LEFT_END_MARK: Final[tuple[float, float, float]] = (0.95, 0.02, 0.02) #: the color for markers at the right end __RIGHT_END_MARK: Final[tuple[float, float, float]] = (0.02, 0.02, 0.95) #: the color for markers in the middle __MIDDLE_MARK: Final[tuple[float, float, float]] = pd.COLOR_BLACK
[docs] def plot_gantt_chart( gantt: Gantt | str, figure: Axes | Figure, markers: Iterable[tuple[str, int | float] | Callable[ [Gantt], tuple[str, int | float]]] | None = (marker_lb,), x_axis: AxisRanger | Callable[[Gantt], AxisRanger] = lambda gantt: AxisRanger(chosen_min=0), importance_to_line_width_func: Callable[[int], float] = pd.importance_to_line_width, importance_to_font_size_func: Callable[[int], float] = pd.importance_to_font_size, info: None | str | Callable[[Gantt], str] = lambda gantt: Lang.current().format_str("gantt_info", gantt=gantt), x_grid: bool = False, y_grid: bool = False, x_label: None | str | Callable[[Gantt], str] = Lang.translate_call("time"), x_label_inside: bool = True, x_label_location: float = 1.0, y_label: None | str | Callable[[Gantt], str] = Lang.translate_call("machine"), y_label_inside: bool = True, y_label_location: float = 0.5) -> Axes: """ Plot a Gantt chart. :param gantt: the gantt chart or a path to a file to load it from :param figure: the figure :param markers: a set of markers :param x_axis: the ranger for the x-axis :param info: the optional info header :param importance_to_font_size_func: the function converting importance values to font sizes :param importance_to_line_width_func: the function converting importance values to line widths :param x_grid: should we have a grid along the x-axis? :param y_grid: should we have a grid along the y-axis? :param x_label: a callable returning the label for the x-axis, a label string, or `None` if no label should be put :param x_label_inside: put the x-axis label inside the plot (so that it does not consume additional vertical space) :param x_label_location: the location of the x-label :param y_label: a callable returning the label for the y-axis, a label string, or `None` if no label should be put :param y_label_inside: put the y-axis label inside the plot (so that it does not consume additional horizontal space) :param y_label_location: the location of the y-label :returns: the axes object to allow you to add further plot elements """ if isinstance(gantt, str): gantt = Gantt.from_log(gantt) if not isinstance(gantt, Gantt): raise type_error(gantt, "gantt", (Gantt, str)) axes: Final[Axes] = pu.get_axes(figure) # grab the data jobs: Final[int] = gantt.instance.jobs machines: Final[int] = gantt.instance.machines # Set up the x-axis range. if callable(x_axis): x_axis = x_axis(gantt) if not isinstance(x_axis, AxisRanger): raise type_error(x_axis, "x_axis", AxisRanger) # Compute all the marks marks: dict[int | float, str] = {} if markers is not None: if not isinstance(markers, Iterable): raise type_error(markers, "markers", Iterable) for usemarker in markers: marker = usemarker(gantt) if callable(usemarker) else usemarker if not marker: continue if isinstance(marker, tuple): name, val = marker if (not name) or (not val): continue else: raise type_error(marker, "marker", tuple, True) if not isinstance(name, str): raise type_error(name, "marker name", str) if not isinstance(val, int | float): raise type_error(val, "marker", (int, float)) if val in marks: marks[val] = f"{marks[val]}/{name}" else: marks[val] = name x_axis.register_value(val) # Add x-axis data x_axis.register_array(gantt[:, 0, 1].flatten()) # register start times x_axis.register_array(gantt[:, -1, 2].flatten()) # register end times x_axis.apply(axes, "x") xmin, xmax = axes.get_xlim() # Set up the y-axis range. height: Final[float] = 0.7 bar_ofs: Final[float] = height / 2 y_min: Final[float] = -((7 * bar_ofs) / 6) y_max: Final[float] = (machines - 1) + ((7 * bar_ofs) / 6) axes.set_ylim(y_min, y_max) axes.set_ybound(y_min, y_max) axes.yaxis.set_major_locator(MaxNLocator(nbins="auto", integer=True)) # get the color and font styles colors: Final[tuple] = pd.distinct_colors(jobs) font_size: Final[float] = importance_to_font_size_func(-1) # get the transforms needed to obtain text dimensions rend: Final = pu.get_renderer(axes) inv: Final = axes.transData.inverted() # draw the grid if x_grid or y_grid: grid_lwd = importance_to_line_width_func(-1) if x_grid: axes.grid(axis="x", color=pd.GRID_COLOR, linewidth=grid_lwd) if y_grid: axes.grid(axis="y", color=pd.GRID_COLOR, linewidth=grid_lwd) z_order: int = 0 # print the marker lines for val, _ in marks.items(): axes.add_artist(Line2D(xdata=(val, val), ydata=(y_min, y_max), color=__LEFT_END_MARK if val <= xmin else __RIGHT_END_MARK if val >= xmax else __MIDDLE_MARK, linewidth=2.0, zorder=z_order)) z_order += 1 # plot the jobs for machine in range(machines): for jobi in range(jobs): job, x_start, x_end = gantt[machine, jobi, :] if x_end <= x_start: # skip operations that take 0 time continue background = colors[job] foreground = pd.text_color_for_background(colors[job]) jobstr = str(job) # first plot the colored rectangle y_start = machine - bar_ofs axes.add_artist(Rectangle( xy=(x_start, y_start), width=(x_end - x_start), height=height, color=background, linewidth=0, zorder=z_order)) z_order += 1 # Now we insert the job IDs, which is a bit tricky: # First, the rectangle may be too small to hold the text. # So we need to get the text bounding box size to compare it with # the rectangle's size. # If that fits, we can print the text. # Second, the printed text tends to be slightly off vertically, # even if we try to center it vertically. This is because fonts # can extend below their baseline which seems to be considered # during vertical alignment although job IDs are numbers and that # does not apply here. Therefore, we try to re-adjust the boxes # in a very, very crude way. xp: float = 0.5 * (x_start + x_end) yp: float | int = machine # Get the size of the text using a temporary text # that gets immediately deleted again. tmp: Text = axes.text(x=xp, y=yp, s=jobstr, fontsize=font_size, color=foreground, horizontalalignment="center", verticalalignment="baseline") bb_bl = inv.transform_bbox(tmp.get_window_extent( renderer=rend)) Artist.set_visible(tmp, False) Artist.remove(tmp) del tmp if (bb_bl.width < 0.97 * (x_end - x_start)) and \ (bb_bl.height < (0.97 * height)): # OK, there is enough space. Let's re-compute the y # offset to do proper alignment using another temporary # text. tmp = axes.text(x=xp, y=yp, s=jobstr, fontsize=font_size, color=foreground, horizontalalignment="center", verticalalignment="bottom") bb_bt = inv.transform_bbox(tmp.get_window_extent( renderer=rend)) Artist.set_visible(tmp, False) Artist.remove(tmp) del tmp # Now we can really print the actual text with a more or less # nice vertical alignment. adj = bb_bl.y0 - bb_bt.y0 if adj < 0: yp += adj / 3 axes.text(x=xp, y=yp, s=jobstr, fontsize=font_size, color=foreground, horizontalalignment="center", verticalalignment="center", zorder=z_order) z_order += 1 # print the marker labels bbox = {"boxstyle": "round", "color": "white", "fill": True, "linewidth": 0, "alpha": 0.9} y_mark: Final[float] = -0.1 # machines - 1 + (0.9 * bar_ofs) for top for val, name in marks.items(): axes.annotate(text=f"{name}={val}", xy=(val, y_mark), xytext=(-4, -4), verticalalignment="bottom", horizontalalignment="right", xycoords="data", textcoords="offset points", fontsize=font_size, color=__LEFT_END_MARK if val <= xmin else __RIGHT_END_MARK if val >= xmax else __MIDDLE_MARK, rotation=90, bbox=bbox, zorder=z_order) z_order += 1 info_font_size: Final[float] = importance_to_font_size_func(0) pu.label_axes(axes=axes, x_label=x_label(gantt) if callable(x_label) else x_label, x_label_inside=x_label_inside, x_label_location=x_label_location, y_label=y_label(gantt) if callable(y_label) else y_label, y_label_inside=y_label_inside, y_label_location=y_label_location, font_size=info_font_size, z_order=z_order) z_order = z_order + 1 if callable(info): info = info(gantt) if info is not None: if not isinstance(info, str): raise type_error(info, "info", str) pu.label_box(axes=axes, text=info, x=0.5, y=1, font_size=importance_to_font_size_func(1), may_rotate_text=False, z_order=z_order) return axes