Files
ETPlot/plotstyle/CLAUDE.md
T
lars eeb483a4fa Add ps.no_spines() for imshow/pcolormesh/2D-histogram plots
Image- and bin-indexed plots have no meaningful x baseline, so the
themed bottom spine kept by use() doesn't apply to them. Adds an
explicit opt-in to disable all spines on such Axes, documents it in
plotstyle/CLAUDE.md, and updates the showcase notebook's colormap and
multi-panel examples to use it.

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

15 KiB
Raw Blame History

CLAUDE.md — plotstyle

Guidance for Claude Code (or any coding agent) writing or editing scripts that use plotstyle. This package is a standalone matplotlib styling toolkit, decoupled from the gallery/ package in this repo — gallery/ never imports it. The connection between the two is a file on disk: plotstyle produces PDF figures, and gallery (elsewhere in this repo) turns a directory of PDFs into an HTML gallery. See the bottom of this file for that handoff.

What it is

A KIT (Karlsruhe Institute of Technology) corporate-design matplotlib theme plus a handful of building-block functions, so every figure produced for a thesis chapter or a talk slide looks consistent — validated color palette, consistent spines/ticks/grid, LaTeX text in a modern sans font, figure titles with a parameters subtitle, and legend/panel-label helpers.

Read examples/plotstyle_showcase.ipynb (repo root) for a fully rendered, end-to-end tour — it's the fastest way to see what every function actually produces. Everything below is the reference; the notebook is the demo.

Install

uv sync --extra plotting          # this repo's uv workflow
# or
pip install -e ".[plotting]"

plotstyle is an optional extra (matplotlib>=3.7) so core gallery installs stay lightweight — don't add matplotlib to gallery's own unconditional dependencies to support this package.

Hard requirement: a working local LaTeX toolchain (latex + dvipng). plotstyle.use() sets text.usetex = True unconditionally — there is no mathtext fallback. If a script using plotstyle needs to run somewhere LaTeX isn't installed, that's a real environment gap to flag, not something to silently work around in plotstyle itself (that decision was made deliberately across several iterations of this package — don't reintroduce a fallback without being asked).

Quick start

import numpy as np
import plotstyle as ps

ps.use()  # once, before creating any figure

fig, ax = ps.new_figure(
    "thesis-single",
    title="Measured signal",
    params={"N": 512, "sigma": 1.2, "seed": 42},
)
ax.plot(np.linspace(0, 10, 200), np.sin(np.linspace(0, 10, 200)), label="signal")
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"Amplitude $A(t)$")
ps.style_legend(ax, title="Series")
ps.savefig(fig, "plots/measured_signal", formats=("pdf",))

API reference

Everything is re-exported at the top level (import plotstyle as ps); the submodule layout (style.py, colors.py, figures.py, annotations.py) is an implementation detail, not part of the interface agents should reach into.

ps.use(cycle_linestyles: bool = False)

Applies the theme to matplotlib's global rcParams. Call this once, near the top of the script, before creating any figure. Sets:

  • Only the bottom spine visible, colored black and heavier than default (axes.edgecolor/axes.linewidth) — left/top/right spines off.
  • Ticks on left + bottom only; x-axis gets shorter minor ticks between the major ones (y-axis doesn't — its horizontal gridlines already mark position). Major tick labels read as dark ink, minor tick labels lighter grey.
  • Horizontal-only, light-grey gridlines.
  • Left-aligned axes titles (axes.titlelocation: left).
  • axes.prop_cycle = the 9 KIT categorical colors, in fixed order. Color only by default — pass cycle_linestyles=True to also cycle linestyle (solid/dashed/dash-dot/dotted), which matters if a figure might be printed in grayscale or viewed by someone with color-vision deficiency.
  • text.usetex = True with Latin Modern Sans (lmodern + sfmath so math-mode text is sans too, not just body text; fontenc T1 so plain ASCII like | doesn't render as the wrong glyph under LaTeX's OT1 default).

ps.reset() restores matplotlib defaults (plt.rcdefaults()) — useful between notebook cells or in tests, not normally needed in a script.

ps.new_figure(preset="thesis-single", *, title=None, params=None, **subplots_kwargs)

Thin wrapper over plt.subplots(). Returns (fig, ax) or (fig, axes) exactly like plt.subplots()**subplots_kwargs (nrows, ncols, sharex, ...) pass straight through.

  • preset: one of ps.FIGSIZES"thesis-single" (6×4"), "thesis-wide" (8×4.5"), "slide-16x9" (10×5.625"), "square" (5×5"). Or pass an explicit (w, h) tuple in inches to bypass the presets.
  • title: sets a left-aligned, bold figure-level title via fig.suptitle. Prefer this over ax.set_title() for a single-axes figure — it's the recommended, consistent way to title a plot in this codebase. Reserve ax.set_title() for multi-axes figures, where each panel needs its own title and no single figure title could cover all of them (see the multi-panel example in the notebook).
  • params: an optional dict rendered as a smaller subtitle line under the title: key1: value1 | key2: value2 | .... Good for recording the run parameters that produced a plot (params={"N": 512, "seed": 42}). Note it is not colored differently from the title (see "Known limitation" below) — only smaller.

ps.colorbar(mappable, ax, size="5%", pad=0.05, **kwargs)

Use this instead of fig.colorbar(im, ax=ax) whenever ax has set_aspect("equal") (or anything else that visually shrinks it) — plain fig.colorbar sizes to the axes' nominal bounding box and ends up taller than what's actually drawn. This appends a matching-size axes via mpl_toolkits.axes_grid1.make_axes_locatable and also turns off the colorbar's own border (cb.outline.set_visible(False)), which otherwise independently picks up the bold black spine styling as a stray box around the colorbar.

ps.no_spines(ax)

Hides all four spines on ax (or every Axes in an array, e.g. from new_figure(nrows=..., ncols=...)). use() keeps only the bottom spine visible by default, since most plots have a meaningful x baseline — but pixel/bin-indexed plots (imshow, pcolormesh, 2D histograms) don't have one, so the themed bottom spine implies an axis origin that doesn't mean anything there. Call this on the Axes for that kind of plot instead of leaving the bottom spine on or hand-rolling ax.spines[...].set_visible(False):

im = ax.imshow(image_data)
ps.no_spines(ax)
ps.colorbar(im, ax, label="Intensity")

ps.style_legend(ax, loc="outside right upper", frameon=False, title=None, **kwargs)

Builds a legend from ax's handles/labels but attaches it to the figure (fig.legend(...)), so it always sits outside the axes rather than overlapping data. Pass title= — strongly encouraged; omitting it prints a UserWarning (the legend still renders, so this won't break a script, but an agent generating new plots should always pass one).

ps.panel_label(ax, label, loc="lower right", fontweight="bold", box=True, **kwargs)

Adds a (a)/(b)/… label for multi-panel figures. Defaults to the bottom-right corner (nudged up from the very edge so it clears the x-axis), colored to match ax's xlabel, on a light-grey semi-transparent rounded box with a slim solid border. Pass box=False for bare text. Don't hand-roll this with ax.text(...) — use the helper so every panel label in a figure (and across figures) looks the same.

ps.savefig(fig, path, formats=("pdf",), dpi=300)

Writes one file per format (path has no extension; each format is appended). Default to formats=("pdf",) — see "Combining with gallery" below for why PDF is what you almost always want here. Creates parent directories automatically.

ps.get_color(i) / ps.colors

ps.get_color(i) indexes the 9-color categorical palette (0-based) and raises ValueError past the last slot — never wrap/cycle back to 0 yourself past index 8; fold extra series into an "Other" bucket or facet instead. Prefer relying on the default prop_cycle (i.e. just call ax.plot(...) repeatedly without specifying color=) over calling get_color() explicitly, unless you need a specific slot out of order (e.g. matching a color used elsewhere in the same figure).

ps.colors also exposes, if you need direct access:

  • CATEGORICAL — the 9 hex strings, in order.
  • sequential_cmap() — continuous KIT-blue colormap (light tint → brand blue) for magnitude/heatmap data.
  • diverging_cmap() — KIT blue ↔ KIT red through a neutral grey midpoint, for signed data. Always pass symmetric vmin/vmax around the data's true zero when using it.
  • STATUS — fixed good/warning/serious/critical colors. Never put these in a categorical series cycle; only use them for actual good/bad-style status encoding, always paired with a label.
  • INK — the grey/text roles (primary, secondary, muted, gridline, baseline, surface) the theme itself is built from.

Best practices (for agents writing or reviewing plot scripts)

  1. Call ps.use() once, before any figure is created. Don't call it again mid-script unless deliberately toggling cycle_linestyles back and forth (rare — only useful when a notebook wants to show both modes).
  2. Prefer new_figure(title=..., params=...) over ax.set_title() for any single-axes figure. Use ax.set_title() only per-panel in multi-axes figures.
  3. Never use literal # or % in any text passed to matplotlib (titles, labels, legend entries, annotations) while plotstyle is active — usetex is always on, and those are LaTeX special characters that will break rendering with a RuntimeError from latex. Rephrase instead of escaping where possible (e.g. a hex color used as a label should be spelled without its #; a percentage should read "42 percent" or use an escaped \% if you specifically need the glyph).
  4. Don't hand-style spines/ticks/grid/legend/panel-labels manually — that's what use(), style_legend(), and panel_label() are for. If an agent finds itself writing ax.spines[...].set_visible(...) or similar in a script that already calls ps.use(), that's very likely fighting the theme rather than working with it — stop and reconsider. The one sanctioned exception is ps.no_spines(ax) on imshow/pcolormesh/2D histogram Axes, where the themed bottom spine implies a baseline that doesn't exist for pixel/bin data.
  5. Default savefig(..., formats=("pdf",)). Only add "png"/"svg" if there's a concrete reason (e.g. a quick raster preview outside the gallery pipeline) — seeing formats=("pdf", "png") in a new script is a signal to ask why, since the gallery already produces its own PNG thumbnails from the PDF.
  6. This package has no test/CI dependency on a real LaTeX install being absent — the test suite (tests/test_plotstyle.py) assumes LaTeX is present (this repo's dev machine has it), and exercises real rendering rather than mocking it out. Don't add a mathtext-fallback code path to make tests pass in a hypothetical no-LaTeX CI without being asked; that would silently reintroduce the fallback behavior that was deliberately removed.
  7. Known limitation, don't try to route around it: a figure title and its params subtitle can't have different colors (matplotlib's usetex rendering tints an entire Text artist with one color; any in-source \color/\textcolor is ignored). They're differentiated by size (\small) only. If asked to make the subtitle a different color, the real fix requires a second, independently-positioned Text artist with its own color — flag the added complexity rather than quietly reaching for \textcolor again.

plotstyle and gallery never share code or imports — the only connection is that gallery recursively scans source directories (configured in gallery's config.yaml, see repo-root CLAUDE.md) for PDF/HTML files plus metadata.yaml/.yml/.json files, and turns them into a static site. gallery does its own PDF→PNG conversion at a configured DPI — so a plotstyle script only needs to produce the PDF; don't also generate a PNG "for the gallery" (that's gallery's job, and a hand-made PNG would just be redundant/inconsistent with the thumbnail gallery generates itself).

End-to-end workflow

  1. Pick or create a source directory for the project's plots — this can be anywhere on disk, it does not need to live inside this repo (e.g. ~/experiments/run42/plots/). Subdirectories inside it become the gallery's folder hierarchy.

  2. Write the plotting script using plotstyle, saving into that directory:

    import plotstyle as ps
    
    ps.use()
    fig, ax = ps.new_figure("thesis-single", title="Beam profile", params={"run": 42})
    # ... plot ...
    ps.savefig(fig, "/home/user/experiments/run42/plots/beam_profile/x_projection", formats=("pdf",))
    
  3. Add a metadata.yaml in any folder of that source tree to annotate every plot within it (and its subfolders — metadata inherits downward, child keys override parent keys). Fields are freeform YAML — there's no fixed schema — but a few keys get special, prominent placement in the per-plot popup UI: title, description, plot_type, experiment. Everything else still displays, just under "Additional Information".

    # metadata.yaml
    title: "Run 42 — Beam Profile Measurements"
    description: "Transverse beam profiles at IP1, measured with the wire scanner."
    experiment: "Run 42"
    plot_type: "beam-profile"
    
    parameters:
      beam_energy: "6.5 TeV"
      bunch_intensity: "1.1e11"
    
    tags:
      - "beam-diagnostics"
      - "ip1"
    
    • Also accepts .yml/.json instead of .yaml.
    • Per-plot override: create <plotname>.yaml next to <plotname>.pdf (matching the PDF's stem) with just the keys to override for that one plot — it's merged on top of the inherited folder metadata.
    • Long text values (>100 chars), lists, and nested mappings all render sensibly in the UI automatically (truncated-with-"show more", tags, nested key/value blocks respectively) — no special formatting needed on the Python/YAML side.
    • Metadata text values support inline LaTeX, rendered client-side via MathJax, e.g. formula: "$$E = mc^2$$" or luminosity: "35.9 fb^{-1}".
  4. Register the source in gallery's config.yaml if it isn't already there:

    sources:
      - name: "run42"
        path: "/home/user/experiments/run42/plots"
    
  5. Generate (or update) the gallery:

    gallery generate --source /home/user/experiments/run42/plots --verbose
    

    Incremental: gallery only reconverts a PDF to PNG if the PDF is newer than the cached PNG (with a 30s buffer — see repo-root CLAUDE.md), so re-running a plotstyle script that overwrites the same PDF path is exactly the intended update flow.