Files
ETPlot/tests/test_plotstyle.py
T
lars 36ae18e880 Add plotstyle: reusable KIT-branded matplotlib styling toolkit
Introduces a new plotstyle package providing consistent, presentation/thesis-ready
matplotlib figures: a KIT corporate-design color palette (categorical, sequential,
diverging, status), a theme applied via use() (KIT-black bottom spine only, left/bottom
ticks, horizontal gridlines, left-aligned titles, LaTeX text in Latin Modern Sans),
new_figure() with size presets and figure-level title/params subtitles, a
same-size colorbar() helper, style_legend() and panel_label() building blocks, and
savefig(). Ships as an optional "plotting" extra so core gallery installs stay
lightweight, with a full test suite and a runnable example notebook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:19:30 +02:00

311 lines
9.0 KiB
Python

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from matplotlib.colors import Colormap
import plotstyle as ps
from plotstyle import colors
def test_categorical_palette_matches_kit_hex():
assert colors.CATEGORICAL == [
"#009682",
"#DF9B1B",
"#4664AA",
"#A78230",
"#23A1E0",
"#A3107C",
"#8CB63C",
"#A22223",
"#FCE500",
]
def test_diverging_palette_matches_kit_hex():
assert colors.DIVERGING == {"low": "#4664AA", "mid": "#f7f7f7", "high": "#A22223"}
def test_status_palette_matches_validated_hex():
assert colors.STATUS == {
"good": "#0ca30c",
"warning": "#fab219",
"serious": "#ec835a",
"critical": "#d03b3b",
}
def test_get_color_returns_categorical_slot():
assert colors.get_color(0) == colors.CATEGORICAL[0]
assert colors.get_color(len(colors.CATEGORICAL) - 1) == colors.CATEGORICAL[-1]
def test_get_color_out_of_range_raises():
with pytest.raises(ValueError):
colors.get_color(len(colors.CATEGORICAL))
def test_sequential_steps_are_kit_blue_variations():
assert colors.SEQUENTIAL_STEPS[-1].lower() == "#4664aa"
# Lightest step should be a lighter (higher-lightness) tint than the brand color.
import colorsys
def lightness(hex_color):
r, g, b = (int(hex_color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))
return colorsys.rgb_to_hls(r, g, b)[1]
assert lightness(colors.SEQUENTIAL_STEPS[0]) > lightness(colors.SEQUENTIAL_STEPS[-1])
def test_sequential_cmap_is_usable_colormap():
cmap = colors.sequential_cmap()
assert isinstance(cmap, Colormap)
assert cmap(0.0) != cmap(1.0)
def test_diverging_cmap_is_usable_colormap():
cmap = colors.diverging_cmap()
assert isinstance(cmap, Colormap)
assert cmap(0.0) != cmap(1.0)
def test_use_sets_expected_rcparams():
ps.use()
try:
assert plt.rcParams["axes.facecolor"] == "#ffffff"
assert plt.rcParams["text.usetex"] is True
assert "lmodern" in plt.rcParams["text.latex.preamble"]
assert "sfmath" in plt.rcParams["text.latex.preamble"]
assert "fontenc" in plt.rcParams["text.latex.preamble"]
assert r"\sfdefault" in plt.rcParams["text.latex.preamble"]
cycle_colors = [entry["color"] for entry in plt.rcParams["axes.prop_cycle"]]
assert cycle_colors == colors.CATEGORICAL
assert plt.rcParams["axes.spines.top"] is False
assert plt.rcParams["axes.spines.right"] is False
assert plt.rcParams["axes.spines.left"] is False
assert plt.rcParams["axes.spines.bottom"] is True
assert plt.rcParams["xtick.bottom"] is True
assert plt.rcParams["ytick.left"] is True
assert plt.rcParams["axes.edgecolor"] == "#000000"
assert plt.rcParams["axes.linewidth"] > 0.8
assert plt.rcParams["axes.grid"] is True
assert plt.rcParams["axes.grid.axis"] == "y"
assert plt.rcParams["axes.titlelocation"] == "left"
assert plt.rcParams["xtick.minor.visible"] is True
assert plt.rcParams["ytick.minor.visible"] is False
assert plt.rcParams["xtick.major.size"] > plt.rcParams["xtick.minor.size"]
finally:
ps.reset()
def test_use_default_does_not_cycle_linestyles():
ps.use()
try:
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
assert cycle_keys == {"color"}
finally:
ps.reset()
def test_use_cycle_linestyles_opt_in():
ps.use(cycle_linestyles=True)
try:
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
assert cycle_keys == {"color", "linestyle"}
linestyles = {entry["linestyle"] for entry in plt.rcParams["axes.prop_cycle"]}
assert len(linestyles) > 1
finally:
ps.reset()
def test_new_figure_styles_major_and_minor_tick_labels_differently():
fig, ax = ps.new_figure("square")
try:
assert ax.xaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
assert ax.xaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
assert ax.yaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
assert ax.yaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
finally:
plt.close(fig)
def test_new_figure_preset_sizes():
fig, ax = ps.new_figure("thesis-single")
try:
assert tuple(fig.get_size_inches()) == ps.FIGSIZES["thesis-single"]
finally:
plt.close(fig)
def test_new_figure_explicit_tuple():
fig, ax = ps.new_figure((3.0, 2.0))
try:
assert tuple(fig.get_size_inches()) == (3.0, 2.0)
finally:
plt.close(fig)
def test_new_figure_unknown_preset_raises():
with pytest.raises(ValueError):
ps.new_figure("not-a-real-preset")
def test_new_figure_title_sets_left_aligned_figure_suptitle():
fig, ax = ps.new_figure("square", title="My Title")
try:
assert fig._suptitle is not None
assert "My Title" in fig._suptitle.get_text()
assert fig._suptitle.get_ha() == "left"
assert ax.get_title() == ""
finally:
plt.close(fig)
def test_new_figure_params_adds_subtitle_line():
fig, ax = ps.new_figure("square", title="My Title", params={"N": 100, "seed": 42})
try:
text = fig._suptitle.get_text()
assert "My Title" in text
assert "N: 100" in text
assert "seed: 42" in text
assert "|" in text
finally:
plt.close(fig)
def test_new_figure_without_title_or_params_has_no_suptitle():
fig, ax = ps.new_figure("square")
try:
assert fig._suptitle is None
finally:
plt.close(fig)
def test_colorbar_matches_axes_height():
fig, ax = ps.new_figure("square")
try:
im = ax.imshow([[0, 1], [2, 3]])
cb = ps.colorbar(im, ax)
fig.canvas.draw()
ax_bbox = ax.get_position()
cax_bbox = cb.ax.get_position()
assert ax_bbox.y0 == pytest.approx(cax_bbox.y0, abs=1e-6)
assert ax_bbox.y1 == pytest.approx(cax_bbox.y1, abs=1e-6)
finally:
plt.close(fig)
def test_colorbar_has_no_outline():
fig, ax = ps.new_figure("square")
try:
im = ax.imshow([[0, 1], [2, 3]])
cb = ps.colorbar(im, ax)
assert cb.outline.get_visible() is False
finally:
plt.close(fig)
def test_savefig_writes_requested_formats(tmp_path):
fig, ax = ps.new_figure("square")
ax.plot([0, 1], [0, 1])
try:
out_dir = tmp_path / "nested" / "plots"
written = ps.savefig(fig, out_dir / "my_plot", formats=("pdf", "png"))
finally:
plt.close(fig)
assert [p.name for p in written] == ["my_plot.pdf", "my_plot.png"]
for p in written:
assert p.exists()
assert p.stat().st_size > 0
def test_style_legend_places_legend_on_figure_outside_axes():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
legend = ps.style_legend(ax, title="Series")
assert legend in fig.legends
assert ax.get_legend() is None
assert legend.get_title().get_text() == "Series"
finally:
plt.close(fig)
def test_style_legend_without_title_warns():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
with pytest.warns(UserWarning):
ps.style_legend(ax)
finally:
plt.close(fig)
def test_panel_label_does_not_raise():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
ps.panel_label(ax, "a")
finally:
plt.close(fig)
def test_panel_label_unknown_loc_raises():
fig, ax = ps.new_figure("square")
try:
with pytest.raises(ValueError):
ps.panel_label(ax, "a", loc="middle")
finally:
plt.close(fig)
def test_panel_label_defaults_to_lower_right():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a")
assert text.get_ha() == "right"
assert text.get_va() == "bottom"
x, y = text.get_position()
assert x > 0.5
assert y < 0.5
finally:
plt.close(fig)
def test_panel_label_color_matches_xlabel():
fig, ax = ps.new_figure("square")
try:
ax.set_xlabel("Time (s)")
text = ps.panel_label(ax, "a")
assert text.get_color() == ax.xaxis.label.get_color()
finally:
plt.close(fig)
def test_panel_label_has_rounded_semi_transparent_box_by_default():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a")
patch = text.get_bbox_patch()
assert patch is not None
assert "round" in patch.get_boxstyle().__class__.__name__.lower()
assert patch.get_facecolor()[3] < 1.0
assert patch.get_edgecolor()[3] == 1.0
finally:
plt.close(fig)
def test_panel_label_box_can_be_disabled():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a", box=False)
assert text.get_bbox_patch() is None
finally:
plt.close(fig)