Source code for moptipy.utils.plot_utils
"""Utilities for creating and storing figures."""
import os.path
import statistics as st
import warnings
from math import isfinite, sqrt
from typing import Any, Callable, Final, Iterable, Sequence, cast
import matplotlib.pyplot as plt # type: ignore
from matplotlib import rcParams # type: ignore
from matplotlib.artist import Artist # type: ignore
from matplotlib.axes import Axes # type: ignore
from matplotlib.backend_bases import RendererBase # type: ignore
from matplotlib.backends.backend_agg import RendererAgg # type: ignore
from matplotlib.figure import Figure, SubFigure # type: ignore
from pycommons.io.path import Path, directory_path
from pycommons.types import check_int_range, type_error, type_name_of
import moptipy.utils.plot_defaults as pd
from moptipy.utils.lang import Lang
# Ensure that matplotlib uses Type 1 fonts.
# Some scientific conferences, such as GECCO organized by ACM, require this.
# In the language utilities :meth:`~moptipy.utils.lang.Lang.font`, we do
# return acceptable fonts anyway, but it may be better to set this here
# explicitly to avoid any problem.
rcParams["pdf.fonttype"] = 42
rcParams["ps.fonttype"] = 42
#: The golden ratio constant with value 1.618033988749895.
__GOLDEN_RATIO: Final[float] = 0.5 + (0.5 * sqrt(5))
[docs]
def create_figure(width: float | int | None = 8.6,
height: float | int | None = None,
dpi: float | int | None = 384.0,
**kwargs) -> Figure:
"""
Create a matplotlib figure.
:param width: the optional width
:param height: the optional height
:param dpi: the dpi value
:param kwargs: a set of optional arguments
:return: the figure option
"""
# Check the size, i.e., width and height and use
put_size: bool = True
if width is None:
if height is None:
put_size = False
else:
width = __GOLDEN_RATIO * height
elif height is None:
height = width / __GOLDEN_RATIO
if put_size:
if isinstance(height, int):
height = float(height)
if not isinstance(height, float):
raise ValueError(f"Invalid height type {type(height)}.")
if not (isfinite(height) and (0.1 < height < 10000.0)):
raise ValueError(f"Invalid height {height}.")
nheight = int(0.5 + (height * 72)) / 72.0
if not (isfinite(nheight) and (0.1 < nheight < 10000.0)):
raise ValueError(f"Invalid height {height} as it maps to "
f"{nheight} after to-point-and-back conversion.")
if isinstance(width, int):
width = float(width)
if not isinstance(width, float):
raise ValueError(f"Invalid width type {type(width)}.")
if not (isfinite(width) and (0.1 < width < 10000.0)):
raise ValueError(f"Invalid width {width}.")
nwidth = int(0.5 + (width * 72)) / 72.0
if not (isfinite(nwidth) and (0.1 < nwidth < 10000.0)):
raise ValueError(f"Invalid width {width} as it maps to "
f"{nwidth} after to-point-and-back conversion.")
kwargs["figsize"] = width, height
if dpi is not None:
if isinstance(dpi, int):
dpi = float(dpi)
if not isinstance(dpi, float):
raise ValueError(f"Invalid dpi type {type(dpi)}.")
if not (isfinite(dpi) and (1.0 < dpi < 10000.0)):
raise ValueError(f"Invalid dpi value {dpi}.")
kwargs["dpi"] = dpi
if "frameon" not in kwargs:
kwargs["frameon"] = False
if "constrained_layout" not in kwargs:
kwargs["constrained_layout"] = False
return Figure(**kwargs)
def __divide_evenly(items: int, chunks: int, reverse: bool) -> list[int]:
"""
Divide `n` items into `k` chunks, trying to create equally-sized chunks.
:param items: the number of items to divide
:param chunks: the number of chunks
:param reverse: should we put the additional items at the end (True)
or front (False)
:returns: the list of items
>>> print(__divide_evenly(9, 3, reverse=True))
[3, 3, 3]
>>> print(__divide_evenly(10, 3, reverse=True))
[3, 3, 4]
>>> print(__divide_evenly(10, 3, reverse=False))
[4, 3, 3]
>>> print(__divide_evenly(11, 3, reverse=True))
[3, 4, 4]
>>> print(__divide_evenly(11, 3, reverse=False))
[4, 4, 3]
>>> print(__divide_evenly(12, 3, reverse=False))
[4, 4, 4]
"""
# validate that we do not have more chunks than items or otherwise invalid
# parameters
if (items <= 0) or (chunks <= 0) or (chunks > items):
raise ValueError(f"cannot divide {items} items into {chunks} chunks.")
# First we compute the minimum number of items per chunk.
# Our basic solution is to put exactly this many items into each chunk.
# For example, if items=10 and chunks=3, this will fill 3 items into each
# chunk.
result: list[int] = [items // chunks] * chunks
# We then fill the remaining items into the chunks at the front
# (reverse=False) or end (reverse=True) of the list.
# We do this by putting exactly one such item into each chunk, starting
# from the end.
# Since items modulo chunks must be in [0..chunks), this is possible.
# This setup will yield the lowest possible standard deviation of items
# per chunk.
# In case of items=10 and chunks=3, items % chunks = 1 and the chunks will
# become [3, 3, 4]. If items=3, no items need to be distributed and we get
# [3, 3, 3].
if reverse:
for i in range(1, (items % chunks) + 1):
result[-i] += 1
else:
for i in range(items % chunks):
result[i] += 1
return result
[docs]
def create_figure_with_subplots(
items: int,
max_items_per_plot: int = 3,
max_rows: int = 3,
max_cols: int = 1,
min_rows: int = 1,
min_cols: int = 1,
default_width_per_col: float | int | None = 8.6,
max_width: float | int | None = 8.6,
default_height_per_row: float | int | None = 8.6 / __GOLDEN_RATIO,
max_height: float | int | None = 9,
dpi: float | int | None = 384.0,
plot_config: dict[str, Any] | None = None,
**kwargs) \
-> tuple[Figure, tuple[tuple[Axes | Figure,
int, int, int, int, int], ...]]:
"""
Divide a figure into nrows*ncols sub-plots.
:param items: the number of items to divide
:param max_items_per_plot: the maximum number of items per plot
:param max_rows: the maximum number of rows
:param max_cols: the maximum number of columns
:param min_rows: the minimum number of rows
:param min_cols: the minimum number of cols
:param default_width_per_col: the optional default width of a column
:param default_height_per_row: the optional default height per row
:param max_height: the maximum height
:param max_width: the maximum width
:param dpi: the dpi value
:param kwargs: a set of optional arguments
:param plot_config: the configuration to be applied to all sub-plots
:returns: a tuple with the figure, followed by a series of tuples with
each sub-figure, the index of the first item assigned to it, the
index of the last item assigned to it + 1, their row, their column,
and their overall index
"""
# First, we do a lot of sanity checks
check_int_range(items, "items", 1, 1_000_000)
check_int_range(max_items_per_plot, "max_items_per_plot", 1, 1_000_000)
check_int_range(max_rows, "max_rows", 1, 100)
check_int_range(min_rows, "min_rows", 1, max_rows)
check_int_range(max_cols, "max_cols", 1, 100)
check_int_range(min_cols, "min_cols", 1, max_cols)
if (max_cols * max_rows * max_items_per_plot) < items:
raise ValueError(
f"Cannot distribute {items} items into at most {max_rows} rows "
f"and {max_cols} cols with at most {max_items_per_plot} per "
"plot.")
if default_width_per_col is not None:
default_width_per_col = float(default_width_per_col)
if (not isfinite(default_width_per_col)) \
or (default_width_per_col <= 0.1) \
or (default_width_per_col >= 1000):
raise ValueError(
f"invalid default_width_per_col {default_width_per_col}")
if max_width is not None:
max_width = float(max_width)
if (not isfinite(max_width)) \
or (max_width <= 0.1) or (max_width >= 10000):
raise ValueError(f"invalid max_width {max_width}")
if (default_width_per_col is not None) \
and (default_width_per_col > max_width):
raise ValueError(f"default_width_per_col {default_width_per_col} "
f"> max_width {max_width}")
if default_height_per_row is not None:
default_height_per_row = float(default_height_per_row)
if (not isfinite(default_height_per_row)) \
or (default_height_per_row <= 0.1) \
or (default_height_per_row >= 10000):
raise ValueError(
f"invalid default_height_per_row {default_height_per_row}")
if max_height is not None:
max_height = float(max_height)
if (not isfinite(max_height)) \
or (max_height <= 0.1) or (max_height >= 10000):
raise ValueError(f"invalid max_width {max_height}")
if (default_height_per_row is not None) \
and (max_height < default_height_per_row):
raise ValueError(
f"max_height {max_height} < "
f"default_height_per_row {default_height_per_row}")
# setting up the default dimensions
if default_height_per_row is None:
default_height_per_row = 1.95
if max_height is not None:
default_height_per_row = min(default_height_per_row, max_height)
if max_height is None:
max_height = max_rows * default_height_per_row
if default_width_per_col is None:
default_width_per_col = 8.6
if max_width is not None:
default_width_per_col = min(default_width_per_col, max_width)
if max_width is None:
max_width = default_width_per_col
if (plot_config is not None) and not isinstance(plot_config, dict):
raise type_error(plot_config, "plot_config", (dict, None))
# How many plots do we need at least? Well, if we can put
# max_items_per_plot items into each plot and have items many items, then
# we need ceil(items / max_items_per_plot) many. This is what we compute
# here.
min_plots: Final[int] = (items // max_items_per_plot) \
+ min(1, items % max_items_per_plot)
# The maximum conceivable number of plots would be items, so this we do
# not need to compute.
plots_i: Final[int] = 6
rows_i: Final[int] = plots_i + 1
cols_i: Final[int] = rows_i + 1
height_i: Final[int] = cols_i + 1
width_i: Final[int] = height_i + 1
plots_per_row_i: Final[int] = width_i + 1
chunks_i: Final[int] = plots_per_row_i + 1
best: tuple[float, # the plots-per-row std
float, # the items-per-plot std
float, # the overall area of the figure
float, # the std deviation of (w, h*golden_ratio),
float, # -plot area
float, # the std deviation of (w, h*golden_ratio) per plot
int, # the number of plots
int, # the number of rows
int, # the number of cols
float, # the figure height
float, # the figure width
list[int], # the plots per row
list[int]] = \
(1000000.0, 1000000.0, 1000000.0, 1000000.0, 1000000.0, 1000000.0,
1 << 62, 1 << 62, 1 << 62, 1000000.0, 1000000.0, [0], [0])
# Now we simply try all valid combinations.
for rows in range(min_rows, max_rows + 1):
if rows > items:
continue # more rows than items
for cols in range(min_cols, max_cols + 1):
# We need cols plots per row, except for the last row, where we
# could only put a single plot if we wanted.
min_plots_for_config: int = 1 + ((rows - 1) * cols)
if min_plots_for_config > items:
continue # if this exceeds the maximum number of plots, skip
# compute the maximum plots we can get in this config
max_plots_per_config: int = rows * cols
if max_plots_per_config < min_plots:
continue # not enough plots possible
# now we can iterate over the number of plots we can provide
for plots in range(max(min_plots_for_config, min_plots),
min(max_plots_per_config, items) + 1):
# We compute the figure width. If we have multiple columns,
# we try to add 2% plus 0.02" as breathing space per column.
fig_width = min(max_width, ((1.02 * default_width_per_col)
+ 0.02) * cols)
# The width of a single plot is then computed as follows:
plot_width = fig_width / cols
if plot_width < 0.9:
continue # the single plots would be too small
# We compute the overall figure height. Again we add breathing
# space if we have multiple rows.
fig_height = min(max_height, ((1.02 * default_height_per_row)
+ 0.02) * rows)
plot_height = fig_height / rows
if plot_height < 0.9:
continue # the single plots would be too small
# So dimension-wise, the plot sizes are OK.
# How about the distribution of items? We put the rows with
# plots at the top and the plots with more items at the end.
plot_distr = __divide_evenly(plots, rows, reverse=False)
item_distr = __divide_evenly(items, plots, reverse=True)
current: tuple[float, float, float, float, float, float,
int, int, int, float, float,
list[int], list[int]] = (
st.stdev(plot_distr) if rows > 1 else 0,
st.stdev(item_distr) if plots > 1 else 0,
fig_height * fig_width, # the area of the figure
st.stdev([fig_width, fig_height * __GOLDEN_RATIO]),
-plot_width * plot_height, # -plot area
st.stdev([plot_width, plot_height * __GOLDEN_RATIO]),
plots, # the number of plots
rows, # the number of rows
cols, # the number of cols
fig_height, # the figure height
fig_width, # the figure width
plot_distr, # the plots per row
item_distr) # the data chunks per plot
if ((current[plots_i] + 1) # type: ignore
< best[plots_i]) or (( # type: ignore
current[plots_i] # type: ignore
<= (best[plots_i] + 1)) # type: ignore
and (current < best)): # type: ignore
best = current
n_plots: Final[int] = best[plots_i] # type: ignore
if n_plots > items:
raise ValueError(
f"Could not place {items} in {min_cols}..{max_cols} columns "
f"at {min_rows}..{max_rows} rows with at most "
f"{max_items_per_plot} items per plot for a max_width={max_width}"
f" and max_height={max_height}.")
# create the figure of the computed dimensions
figure: Final[Figure] = create_figure(
width=best[width_i], height=best[height_i], # type: ignore
dpi=dpi, **kwargs)
if (n_plots <= 1) and (plot_config is None):
# if there is only one plot, we are done here
return figure, ((figure, 0, items, 0, 0, 0), )
# if there are multiple plots, we need to generate them
allfigs: list[tuple[Axes | Figure,
int, int, int, int, int]] = []
index: int = 0
chunk_start: int = 0
nrows: Final[int] = best[rows_i] # type: ignore
ncols: Final[int] = best[cols_i] # type: ignore
chunks: Final[list[int]] = best[chunks_i] # type: ignore
plots_per_row: Final[list[int]] = best[plots_per_row_i] # type: ignore
for i in range(nrows):
for j in range(plots_per_row[i]):
chunk_next = chunk_start + chunks[index]
if plot_config is None:
sp = figure.add_subplot(nrows, ncols, (i * ncols) + j + 1)
else:
sp = figure.add_subplot(nrows, ncols, (i * ncols) + j + 1,
**plot_config)
allfigs.append((sp, chunk_start, chunk_next, i, j, index))
chunk_start = chunk_next
index += 1
return figure, tuple(allfigs)
[docs]
def save_figure(fig: Figure,
file_name: str = "figure",
dir_name: str = ".",
formats: str | Iterable[str] = "svg") -> list[Path]:
"""
Store the given figure in files of the given formats and dispose it.
:param fig: the figure to save
:param file_name: the basic file name
:param dir_name: the directory name
:param formats: the file format(s)
:return: a list of files
"""
if not isinstance(fig, Figure):
raise type_error(fig, "figure", Figure)
if not isinstance(file_name, str):
raise type_error(file_name, "file_name", str)
if len(file_name) <= 0:
raise ValueError(f"Invalid filename {file_name!r}.")
if not isinstance(dir_name, str):
raise type_error(dir_name, "dir_name", str)
if len(dir_name) <= 0:
raise ValueError(f"Invalid dirname {dir_name!r}.")
if isinstance(formats, str):
formats = [formats]
if not isinstance(formats, Iterable):
raise type_error(formats, "formats", Iterable)
size = fig.get_size_inches()
orientation: Final[str] = \
"landscape" if size[0] >= size[1] else "portrait"
# set minimal margins to the axes to avoid wasting space
for ax in fig.axes: # consider the dimension of the axes
margins: list[int] = [0] * len(ax.margins())
ax.margins(*margins)
use_dir = directory_path(dir_name)
files = []
for fmt in formats:
if not isinstance(fmt, str):
raise type_error(fmt, "element of formats", str)
dest_file = Path(os.path.join(use_dir, f"{file_name}.{fmt}"))
dest_file.ensure_file_exists()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# UserWarning: There are no gridspecs with layoutgrids.
# Possibly did not call parent GridSpec with the "figure" keyword
fig.savefig(dest_file, transparent=True, format=fmt,
orientation=orientation,
dpi="figure",
bbox_inches="tight",
pad_inches=1.0 / 72.0)
dest_file.enforce_file()
files.append(dest_file)
fig.clf(False)
plt.close(fig)
del fig
if len(files) <= 0:
raise ValueError("No formats were specified.")
return files
[docs]
def label_box(axes: Axes,
text: str,
x: float | None = None,
y: float | None = None,
font_size: float = pd.importance_to_font_size(0),
may_rotate_text: bool = False,
z_order: float | None = None,
font: None | str | Callable =
lambda: Lang.current().font()) -> None:
"""
Put a label text box near an axis.
:param axes: the axes to add the label to
:param text: the text to place
:param x: the location along the x-axis: `0` means left,
`0.5` means centered, `1` means right
:param y: the location along the x-axis: `0` means bottom,
`0.5` means centered, `1` means top
:param font_size: the font size
:param may_rotate_text: should we rotate the text by 90° if that
makes sense (`True`) or always keep it horizontally (`False`)
:param z_order: an optional z-order value
:param font: the font to use
"""
if x is None:
if y is None:
raise ValueError("At least one of x or y must not be None.")
x = 0
elif y is None:
y = 0
spacing: Final[float] = max(4.0, font_size / 2.0)
xtext: float = 0.0
ytext: float = 0.0
xalign: str = "center"
yalign: str = "center"
if x >= 0.85:
xtext = -spacing
xalign = "right"
elif x <= 0.15:
xtext = spacing
xalign = "left"
if y >= 0.85:
ytext = -spacing
yalign = "top"
elif y <= 0.15:
ytext = spacing
yalign = "bottom"
args = {"text": text,
"xy": (x, y),
"xytext": (xtext, ytext),
"verticalalignment": yalign,
"horizontalalignment": xalign,
"xycoords": "axes fraction",
"textcoords": "offset points",
"fontsize": font_size,
"bbox": {"boxstyle": "round",
"color": "white",
"fill": True,
"linewidth": 0,
"alpha": 0.9}}
if z_order is not None:
args["zorder"] = z_order
if may_rotate_text and (len(text) > 2):
args["rotation"] = 90
if callable(font):
font = font()
if font is not None:
if not isinstance(font, str):
raise type_error(font, "font", str)
args["fontname"] = font
axes.annotate(**args) # type: ignore
[docs]
def label_axes(axes: Axes,
x_label: str | None = None,
x_label_inside: bool = True,
x_label_location: float = 0.5,
y_label: str | None = None,
y_label_inside: bool = True,
y_label_location: float = 1,
font_size: float = pd.importance_to_font_size(0),
z_order: float | None = None) -> None:
"""
Put labels on a figure.
:param axes: the axes to add the label to
: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-axis label if it is
placed inside the plot area
: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 xyaxis label inside the plot (so that
it does not consume additional horizontal space)nal vertical space)
:param y_label_location: the location of the y-axis label if it is
placed inside the plot area
:param font_size: the font size to use
:param z_order: an optional z-order value
"""
# put the label on the x-axis, if any
if x_label is not None:
if not isinstance(x_label, str):
raise type_error(x_label, "x_label", str)
if len(x_label) > 0:
if x_label_inside:
label_box(axes, text=x_label, x=x_label_location, y=0,
font_size=font_size, z_order=z_order)
else:
axes.set_xlabel(x_label, fontsize=font_size)
# put the label on the y-axis, if any
if y_label is not None:
if not isinstance(y_label, str):
raise type_error(y_label, "y_label", str)
if len(y_label) > 0:
if y_label_inside:
label_box(axes, text=y_label, x=0, y=y_label_location,
font_size=font_size, may_rotate_text=True,
z_order=z_order)
else:
axes.set_ylabel(y_label, fontsize=font_size)
[docs]
def get_axes(figure: Axes | Figure) -> Axes:
"""
Obtain the axes from a figure or axes object.
:param figure: the figure
:return: the Axes
"""
if isinstance(figure, Figure):
return figure.add_axes((0.005, 0.005, 0.99, 0.99))
if hasattr(figure, "axes") \
or isinstance(getattr(type(figure), "axes", None), property):
try:
if isinstance(figure.axes, Axes):
return cast(Axes, figure.axes)
if isinstance(figure.axes, Sequence):
ax = figure.axes[0]
if isinstance(ax, Axes):
return cast(Axes, ax)
elif isinstance(figure.axes, Iterable):
for k in figure.axes:
if isinstance(k, Axes):
return cast(Axes, k)
break
except TypeError:
pass
except IndexError:
pass
if isinstance(figure, Axes):
return cast(Axes, figure)
raise TypeError(
f"Cannot get Axes of object of type {type_name_of(figure)}.")
[docs]
def get_renderer(figure: Axes | Figure | SubFigure) -> RendererBase:
"""
Get a renderer that can be used for determining figure element sizes.
:param figure: the figure element
:return: the renderer
"""
if isinstance(figure, Axes):
figure = figure.figure
if not isinstance(figure, Figure):
raise type_error(figure, "figure", Figure)
canvas = figure.canvas
if hasattr(canvas, "renderer"):
return canvas.renderer
if hasattr(canvas, "get_renderer"):
return canvas.get_renderer()
return RendererAgg(width=figure.get_figwidth(),
height=figure.get_figheight(),
dpi=figure.get_dpi())
[docs]
def cm_to_inch(cm: int | float) -> float:
"""
Convert cm to inch.
:param cm: the cm value
:return: the value in inch
"""
if not isinstance(cm, int):
if not isinstance(cm, float):
raise type_error(cm, "cm", (int, float))
if not isfinite(cm):
raise ValueError(f"cm must be finite, but is {cm}.")
res: float = cm / 2.54
if not isfinite(res):
raise ValueError(f"Conversation {cm} cm to inch "
f"must be finite, but is {res}.")
return res
#: the color attributes
__COLOR_ATTRS: Final[tuple[tuple[bool, bool, str], ...]] = \
((True, True, "get_label"), (False, True, "label"),
(False, True, "_label"), (True, False, "get_color"),
(False, False, "color"), (False, False, "_color"),
(False, False, "edgecolor"), (False, False, "_edgecolor"),
(False, False, "markeredgecolor"), (False, False, "_markeredgecolor"))
[docs]
def get_label_colors(
handles: Iterable[Artist],
color_map: dict[str, tuple[float, ...] | str] | None = None,
default_color: tuple[float, ...] | str = pd.COLOR_BLACK) \
-> list[tuple[float, ...] | str]:
"""
Get a list with label colors from a set of artists.
:param handles: the handles
:param color_map: an optional color decode
:param default_color: the default color
:returns: a list of label colors
"""
if not isinstance(handles, Iterable):
raise type_error(handles, "handles", Iterable)
def __get_color(a: Artist, colmap=color_map,
defcol=default_color) -> tuple[float, ...] | str:
if not isinstance(a, Artist):
raise type_error(a, "artist", Artist)
for acall, astr, aname in __COLOR_ATTRS:
if hasattr(a, aname):
val = getattr(a, aname)
if val is None:
continue
if acall:
val = val()
if val is None:
continue
if astr:
if colmap:
if val in colmap:
val = colmap[val]
else:
continue
else:
continue
if val is None:
continue
if val != defcol:
return val
return defcol
return [__get_color(aa) for aa in handles]