Skip to content

Plot style gallery

Renders of the same example figure under each registered style (mmml.utils.plotting.styles), so a look can be picked by eye instead of from a description. Regenerate with python scripts/render_plot_style_gallery.py.

Every example also demonstrates the legend-outside-the-plot rule (see docs/plotting-style-guide.md "Legends live outside the plot") via legend_outside(ax) — never overlapping the data, and free to grow long (reads like a small table of series → color/marker) without crowding anything.

Current pick: "icml" — clean sans-serif, muted "seaborn deep"-style categorical colors, moderate (not oversized) type; the closest preset to a modern ML-conference figure. This is what workflows/*/scripts/plot_results.py and plot_structure.py use by default now.

Defaults at a glance

Before picking a style by scrolling through full example figures below, here is every preset's default categorical color cycle, plus the three house colormap defaults (which are shared across all styles — colormaps are chosen by what the data is, sequential/diverging/cyclic, not by which font preset is active). Regenerate with python scripts/render_style_defaults_gallery.py.

Categorical color cycle per style

style palettes

icml's categorical cycle (and so the house default for every plot that doesn't override it) is the Okabe–Ito eight-color palette — designed by Masataka Okabe and Kei Ito specifically to stay distinguishable under protanopia, deuteranopia, and tritanopia. It isn't an opt-in variant: series identity stays legible for colorblind readers by default, not only when a special preset is chosen. ("icml_okabe_ito" still resolves as an alias to "icml" for anything written before the two merged.)

Default colormaps (default_cmap(kind))

default colormaps

Non-color categorical cycles: line styles and markers

Color isn't the only default cycle — LINE_STYLE_CYCLE and MARKER_CYCLE are shown here on the graph type each actually applies to (line styles on a line plot, markers on a scatter), not as an abstract legend key, so what you'll actually see in a figure is what's pictured:

line and marker cycles

Semantic status palette

A separate, reserved palette for encoding state (pass/fail, converged/diverged, in/out of tolerance) — never reused for "the next series in a categorical plot." Four levels (good/warning/serious/ critical), plus neutral for a non-judgmental baseline, each paired with a hatch texture so state doesn't rely on color alone (print-safe, colorblind-safe redundancy — same principle as line styles/markers for categorical identity):

status palette

from mmml.utils.plotting.styles import status_color, status_hatch

ax.bar(labels, values, color=[status_color(s) for s in states],
       hatch=[status_hatch(s) for s in states], edgecolor="#222222")

status_color("success")/status_color("fail") are convenience aliases for "good"/"critical" — same fixed colors, just the common pass/fail spelling for a binary check (e.g. one FD test that's just PASS or FAIL, no borderline state).

icml

icml

The editorial_* family (large-type/thick-line/no-spine variants)

These four share the same axes/spacing treatment — large type, thick lines, no top/right spine, faint grid — only the font differs. Kept in the gallery as an alternative "read from across the room" look for a different kind of figure (e.g. a talk slide) than "icml"'s conference-paper density.

Note on naming: these were previously grouped under one preset literally called "tufte". That was a category error — Tufte described principles (data-ink ratio, redundant encoding, small multiples), not a single font or palette, so no one preset should claim his name. The axis/spacing choices below (no chart junk, minimal spines) draw on those principles; the presets are named editorial_* instead. "tufte" still resolves as a backward-compat alias to editorial_stix, but new code should name the variant directly.

Each example also demonstrates the "semantic color, not palette index" principle from docs/plotting-style-guide.md: the two energy traces are colored by system (not palette order), and the right-hand panel gives bonds/angles/dihedrals three different colors because they're three different physical quantities — not the same blue reused three times (the bug this pass fixed in scripts/plot_trajectory_structure.py::plot_internal).

editorial_dejavu_sans

Matplotlib's bundled sans-serif (DejaVu Sans) — renders identically on every machine, no font-substitution risk.

editorial_dejavu_sans

editorial_dejavu_serif

Matplotlib's bundled serif (DejaVu Serif) — same reliability guarantee as above, traditional serif feel.

editorial_dejavu_serif

editorial_stix

STIX serif — a journal-typeset look (closest to what most physics/chemistry papers use). This was the previous "tufte" preset.

editorial_stix

editorial_cm

DejaVu Serif body text with Computer-Modern-style math (mathtext.fontset="cm") — the classic LaTeX-paper look for equations specifically, while regular text stays DejaVu.

editorial_cm

Chart types (not just fonts)

Same style ("icml"), different chart forms — regenerate with python scripts/render_chart_type_gallery.py. Each one names the specific Tufte principle it demonstrates, since "in line with Tufte's teachings" is about more than axis minimalism — several of his central ideas (small multiples, the range-frame, data-ink ratio, redundant encoding) are concrete, nameable techniques, not just "make it clean." Use these to judge whether a form, not just a font, fits before committing to it for a real figure.

Radial (polar)

A circular histogram of a periodic quantity (dihedral angle). Use the geometry the data actually has — an angle wraps around; forcing it onto a linear 0-360° axis hides the wraparound at the boundary.

radial

3D surface

A dimer PES as an explicit 3D surface. Included because it was asked for, but flagged honestly: 3D is data-ink-expensive (occlusion, projection distortion, no way to read exact values) — Tufte generally preferred 2D + color/contour for exactly this reason. The matshow and range_frame panels below are the better-Tufte alternative for two-variable-vs-response data of this kind.

3d surface

XY scatter with confidence interval

A fitted trend with a shaded 95% CI band, raw points still visible underneath. The band carries the uncertainty; no per-point error bars cluttering every marker.

scatter with CI

Time series with distributions above and below

Not seaborn's jointplot (marginals top+right, sharing the plot's x-axis) — here the two extra panels sit above and below the middle one and share its y-axis instead, showing how the value distribution itself shifts between the first and second half of a run. Two related distributions placed for direct visual comparison, Tufte's small-multiples idea applied to margins instead of a legend/caption.

time series with marginals

Lollipop chart

A thin stem + a dot instead of a filled bar — the most literal data-ink-ratio win in this gallery: nearly all of a bar's ink minus the fill, for the same information (position along the stem = value). See docs/plotting-style-guide.md "Overlaid semi-transparent bars, not more panels" for the sibling rule about when not to add another panel instead.

lollipop

Matrix heatmap (matshow)

A pairwise atom-distance matrix. Color encodes magnitude directly on the (i, j) grid the data already has — no need to invent x/y positions for it the way a scatter would.

matshow

Small multiples

Tufte's signature form, by name: many small, identically-scaled panels, with almost all chart furniture (ticks, boxes, axis labels) stripped since the shape is the point, not reading an exact value off any one panel. Compare directly to the “everything overlaid in one legend-heavy panel” approach used in workflows/*/scripts/plot_results.py — small multiples is the alternative worth considering when a sweep has few enough series to lay out as a grid.

small multiples

Range-frame

Tufte's own named contribution (The Visual Display of Quantitative Information): axis lines span only the data's actual range, not an arbitrary origin-anchored box, with ticks only at min/median/max. Worth calling out explicitly since it's a specific technique, not a generic "minimalist" choice.

range frame

Schematic diagram

Not a data plot — boxes + thin connecting lines for composition/flow (here: how HybridEnergy sums its terms). Same principle applies to diagrams as to charts: no drop shadows/gradients/3D bevels, labels placed directly on the elements they describe rather than pulled out into a legend.

diagram

ASE Atoms as an overlay on a data plot

Not a separate figure next to the data — an ax.inset_axes() holding scripts/plot_utils.py::render_dimer_atoms's ball-and-stick render, placed directly over the point on the curve it corresponds to. Tufte principle: put the explanation where the eye already is, not somewhere the reader has to cross-reference by hand. This is the "good ASE Atoms plot" precedent (jmol element colors, depth-cued alpha, within-fragment-only bonds) applied as an overlay rather than its own panel — see docs/plotting-style-guide.md "Rendering ASE Atoms".

ASE atoms overlay

Colormaps (via the cmap library)

The same 2D field under six colormaps — the house sequential default (default_cmap("sequential"), currently crameri:lipari) alongside picks from cmap (pip/uv package, added to the plotting extra in pyproject.toml), which bundles cmocean, Fabio Crameri's scientific colormaps, ColorBrewer, and more — over 1500 registered names. Not viridis: matplotlib's own default reads fine on its own but isn't part of the house system, so a figure using it looks inconsistent next to everything else built through default_cmap()/MULTI_CMAP_SHORTLIST. crameri:batlow/crameri:vik and cmocean:haline/cmocean:balance are specifically designed to be perceptually uniform and colorblind-safe; colorbrewer:RdYlBu renders as visibly discrete bands here because ColorBrewer's diverging maps are categorical by design, not a bug — a good illustration of continuous vs. binned color encoding being genuinely different choices, not just taste.

colormaps

import cmap
cmap.Colormap("cmocean:haline").to_mpl()  # -> a normal matplotlib Colormap

2D histogram

Binned density instead of an overlapping scatter of 20,000 points — past a few hundred points a scatter is just a saturated blob; the histogram is the honest representation of where the mass actually is.

2D histogram

Histogram time series ("kymograph")

Small multiples taken to their limit: instead of N separate histograms (one per time window) shown side by side, each becomes one column of a single image (x = time, y = value bin, color = local density). A drift in the distribution — not just the mean — becomes one continuous shape instead of something you'd have to notice by comparing many separate panels.

histogram time series

Jitter / strip plot

Every individual observation as one point, x position randomly jittered within its category so overlapping points stay visible instead of stacking into a solid blob. Data-ink principle: nothing here is a derived summary statistic drawn on top of the data — only a short median bar per group — so shape that a mean+errorbar would hide (skew, a bimodal group) stays visible.

jitter/strip plot

Violin plot

The same three groups, but the estimated full distribution shape instead of individual points — complementary to jitter/strip: better once N is large enough that individual points would overplot. The bimodal vacuum group, invisible in a bar+errorbar chart, reads immediately as a violin with two lobes.

violin plot

Colormap picks: choosing defaults

A shortlist of cmap-library colormaps, each rendered on synthetic data suited to its own category (category read from cmap.Colormap(name).category, not guessed) — regenerate with python scripts/render_colormap_picks.py. The goal is to pick one default sequential, one default diverging, and one default cyclic map for the house style, the same way "icml" became the default font/axis preset.

colormap picks overview

Sequential candidates, same field, side by side — smooth density-like data (strictly positive, no natural zero):

sequential picks

Diverging candidates, same zero-centered residual field, side by side:

diverging picks

cmocean:phase (cyclic) is shown separately in the overview above, on a wrapped phase/angle field — the natural fit for periodic quantities like the dihedral-angle radial histogram earlier in this gallery.

Candidate Category Best suited to
cmocean:thermal sequential General-purpose warm sequential; reads well at a glance
matplotlib:terrain sequential Data with a literal "sea level" / physical elevation meaning — misleading otherwise (its blue-green-brown bands imply water/land, not just low/high)
crameri:lipari sequential Perceptually-uniform, colorblind-safe — the safest general default
cmasher:ghostlight sequential High contrast on a black background — good for a dark-mode variant, less so alongside white-background figures
cmasher:horizon sequential Similar dark-background contrast profile to ghostlight
contrib:pampa diverging Muted, low-saturation — won't fight with data ink, good default candidate
cmasher:watermelon diverging High-saturation pink/green — strong visual pop, worse for colorblind readers than the perceptually-uniform options
cmocean:delta diverging Built for velocity/flux fields; teal/gold poles read clearly
cmocean:diff diverging Muted olive/gray — closest in spirit to a "quiet," Tufte-style diverging map
cmasher:guppy_r diverging Vivid blue/orange — good contrast, less perceptually uniform than crameri:vik
yorick:stern misc (HDR) Only for genuinely high-dynamic-range data (one sharp feature over a broad faint background) — not a general sequential substitute
cmocean:phase cyclic The house default for any periodic quantity (angles, phases)

Decided. House defaults, wired into mmml.utils.plotting.styles.default_cmap(kind):

  • Sequential: crameri:lipari
  • Diverging: contrib:pampa
  • Cyclic: cmocean:phase
from mmml.utils.plotting.styles import default_cmap

ax.pcolormesh(xx, yy, zz, cmap=default_cmap("sequential"))

default_cmap raises a clear ImportError (not a silent fallback to an unrelated matplotlib colormap) if the optional cmap library isn't installed — uv sync --extra plotting or pip install cmap.

Merging notebook examples with the house style

A working notebook (phi/psi Ramachandran energy-landscape exploration) had several genuinely nice plot ideas — reworked here under the house style ("icml", legend_outside, big fonts) and default_cmap, using the real 64×64 phi/psi scan (artifacts/trialanine_phi_psi_mm_then_ml_64x64/phi_psi_pes.csv) rather than synthetic data. Regenerate with python scripts/render_ramachandran_gallery.py.

This is also the concrete version of "choosing a colormap category from the data" from the section above — each plot below picks sequential vs. diverging by asking what the data is, not by taste:

Ramachandran scatter (sequential: energy is strictly positive)

MM energy relative to its own minimum has no natural zero to diverge around — default_cmap("sequential").

Ramachandran scatter

Ramachandran contour + raw samples (sequential)

The same data as a smoothed filled contour with the actual sample points overlaid faintly on top — reads both the interpolated landscape and exactly where it was (and wasn't) sampled.

Ramachandran contour

MM vs. ML disagreement (diverging: has a real zero)

ML energy - MM energy is a genuine residual — zero means perfect agreement, positive/negative means ML over/under-estimates relative to MM. This is diverging data, unlike the two panels above — default_cmap("diverging").

MM vs ML diff

Periodic landscape on a torus (sequential, correct topology)

Phi and psi are periodic — φ=−180° and φ=+180° are the same geometry, not adjacent-but-distinct values. A flat Ramachandran plot hides this at its edges; wrapping the same data onto an actual torus removes the artificial seam entirely. Energy is still the encoded quantity (not an angle), so this stays sequential, not cyclic — cmocean:phase (cyclic) would be for coloring by angle itself, e.g. the radial dihedral histogram earlier in this gallery.

periodic torus

MM vs. ML, small multiples

The two landscapes side by side on the same colormap and the same color scale — direct visual comparison, the same principle as the font/chart-type small-multiples above applied to a real MM-vs-ML question.

MM vs ML small multiples

Multipole visualization: a standardized complex figure

Multipole coefficients (charge, dipole, quadrupole, octupole — spherical harmonics indexed by degree l and order m) as one colored, numbered box per (l, m), in a Pascal's-triangle layout (row l has 2l+1 boxes, centered) — regenerate with python scripts/render_multipole_gallery.py. No real saved multipole-coefficient array exists in this checkout (real values come from mmml.models.multipoles at runtime); these use physically-plausible synthetic values matching that module's documented units and packed-irrep convention (see the script's docstring).

Multipole triangle: colormap candidates

Multipole coefficients are signed — diverging data — but the house general diverging default (contrib:pampa) is muted pink/teal, not the classic red-positive/blue-negative convention that reads fastest for this particular, very common plot. A dedicated red/blue shortlist:

multipole colormap candidates

crameri:roma (top right) turned out not to actually be red/blue despite being on the original shortlist — more brown/teal — a good reminder to look at the render, not just the name, before picking.

Decided default: crameri:vik — the classic red-positive/blue-negative read with a controlled, perceptually-uniform ramp (unlike matplotlib's built-in seismic/coolwarm, which are not perceptually uniform). This is complex_figure()'s default cmap_name in scripts/render_multipole_gallery.py.

Each box in the triangle is now drawn with a visible inset gap (plot_multipole_triangle(..., gap=0.08)) rather than edge-to-edge — plain adjacent boxes with only a thin border read as one solid mosaic; the gap lets each (l, m) value read as its own cell.

Standardized complex figure

Multipole triangle + per-atom partial-charge bars + a real LaTeX-typeset booktabs summary table, combined under the "Complex figure layout" rules from docs/plotting-style-guide.md: one shared colorbar for both the triangle and the bars (same colormap, same scale, constrained_layout=True so it doesn't overlap either panel), and latex_table_image() for the summary — compiled with pdflatex and rasterized at 400 DPI, so it has real kerning and decimal-point alignment instead of matplotlib's Axes.table approximation:

multipole standardized complex figure

from mmml.utils.plotting.styles import latex_table_image, latex_available, shared_axis_labels

if latex_available():
    latex_table_image(ax_table, cell_text, col_labels=col_labels)
else:
    booktabs_table(ax_table, cell_text, col_labels=col_labels)  # matplotlib fallback

See docs/plotting-style-guide.md "Tables: LaTeX-typeset booktabs" for why, and when to fall back to booktabs_table().

Fields as parametric surfaces

Where the multipole triangle above shows the coefficients as an abstract (l, m) mosaic, these show the field those coefficients produce in real space — the same parametric-surface idea as the torus, but the natural surface here is a sphere around each source. Regenerate with python scripts/render_multipole_field_gallery.py (mmml.utils.plotting.multipoles).

Multipole surfaces: one deformed sphere per source

Each source's angular electrostatic potential is sampled on a probe sphere and wrapped back onto it — radius bulges with |V|, colour is the signed potential. A bare charge is a round, one-colour sphere; a dipole shows the familiar +/- lobes; a quadrupole is four-lobed. The potential is signed, so colour is diverging about zero (default_cmap("diverging")), the same rule as the Ramachandran difference plot.

multipole surfaces

The scene above is analytic (one clean monopole, dipole, quadrupole) so the lobe structure is unambiguous. The same function drives real predictions: the learned multipole model emits one molecular multipole per fragment, so pointing it at a water trimer (via the committed multipoles_20260711-100037_epoch-0100.json weights) gives one sphere per water. The learned waters carry a small net charge, so the monopole dominates the surface with only weak dipolar structure on top — an honest picture of what this checkpoint predicts, not the idealized lobes:

learned multipole surfaces

Colour is a real choice for signed data, not a default to accept. Every plotter takes a cmap= override — a house kind ("diverging"), a cmap-library name ("crameri:vik", "cmocean:balance"), a matplotlib name, or a Colormap object. The muted house default beside the two classic red/blue reads:

multipole colormap variants

The field itself: potential contours + streamlines

When you want the field of the whole set rather than per-source lobes, plot_field_slice lays a grid on a plane through the sources and draws filled potential contours (diverging, real zero) under electric-field streamlines. It wraps the existing physics in mmml.models.multipoles.electrostatics._point_multipole_potential_field_au; nothing new is computed, only drawn.

multipole field slice

Driven by the committed weights on the same water trimer, this is the field the learned model actually produces:

learned multipole field

Going up the expansion, rank by rank

The multipole expansion is a series, and plot_field_progression shows it as one: a single site's field with the monopole alone, then adding the dipole (l=1), quadrupole (l=2), and octupole (l=3), all on a shared colour scale so the angular structure each rank contributes is directly comparable. The monopole is radial; the dipole breaks the up/down symmetry; the quadrupole adds four lobes; the octupole sharpens the near-field angular detail. Electrostatics defaults to crameri:vik (the classic red-positive/blue-negative read).

multipole field progression

Extending the language to the MBD term

The learned MBD dispersion model predicts a per-atom polarizability and C6 coefficient — both strictly positive, so sequential colour, not diverging. plot_mbd_surfaces draws one sphere per atom (radius ~ α^(1/3), the polarizability volume; colour = C6), the dispersion analogue of the multipole surfaces. Rendered here from the real trained checkpoint on a water cluster: oxygens come out large and high-C6, hydrogens small and low.

mbd polarizability surfaces

And the -Σ C6/r^6 dispersion potential on a plane, the sequential counterpart to the electrostatic field slice above:

mbd dispersion field

Real molecules: optimized, then predicted

The scenes above are analytic, to make the method legible. This row is the real pipeline end to end (scripts/render_multipole_real_molecules.py): four small molecules geometry-optimized on the trained SpookyNet potential, then their learned molecular multipoles predicted on the optimized geometry with the committed multipole weights. The field each molecule produces, side by side — water, ammonia, methanol, formaldehyde:

real optimized-molecule fields

The same molecules as multipole surfaces (probe radius pulled in to 1.4 Å so the dipole/quadrupole/octupole angular structure reads over the monopole):

real optimized-molecule surfaces

And the learned MBD response on the same optimized geometries — heavy atoms carry the large polarizability and C6, hydrogens are small:

real optimized-molecule MBD

Colormaps for a figure with several panels

A figure with more than one sequential (or diverging) panel — one per physical quantity — should not reuse a single colormap for all of them; that erases the fact that the panels encode different things. Pick a distinct map per panel, in a fixed order, from MULTI_CMAP_SHORTLIST:

three sequential colormaps, one figure

from mmml.utils.plotting.styles import MULTI_CMAP_SHORTLIST

names = MULTI_CMAP_SHORTLIST["sequential"][:3]  # or ["diverging"] / ["cyclic"]

MULTI_CMAP_SHORTLIST[kind][0] is always the same as the corresponding default_cmap(kind) — reserve it for the figure's "primary" quantity, and use the later entries for secondary panels, so a repeated panel (e.g. "MM energy" across many workflow figures) keeps the same colormap project-wide.

Line styles, markers, and symbols

Color is not the only axis available — line style (dash pattern) and marker shape are a second, independent categorical encoding, useful when: a figure already spends color on one distinction (e.g. force field) and needs a second one (e.g. replicate) on top of it; the figure must survive grayscale printing; or color-alone identity would fail a colorblind reader even with a safe palette (redundant coding). See LINE_STYLE_CYCLE / MARKER_CYCLE in mmml.utils.plotting.styles and docs/plotting-style-guide.md "Line styles, markers, and symbols" for the full writeup.

Line style = one axis, color = another

line style by role

Marker shape = one axis, color = another

marker style by role

Linewidth/alpha as a visual hierarchy (not categorical at all)

A non-categorical use of the same idea: bold + opaque for the "headline" series, thin + faint for supporting context, so the eye finds the point without reading the legend first.

linewidth/alpha hierarchy

Finite-difference validation: analytic vs. numerical forces

A finite-difference (FD) check compares an analytic force (autodiff, or a hand-derived gradient) against a central-difference numerical derivative of the energy — the standard way to catch a sign error, a missing term, or a broken chain rule in a force implementation. Regenerate with python scripts/render_fd_test_gallery.py. Two real checks, deliberately shown side by side rather than cherry-picking the passing one:

  • SMD bias restraint (mmml.md.energy.terms.SMDBiasTerm, the same check as tests/unit/test_md_energy_terms.py::test_ase_forces_match_finite_difference) — PASSES, atol=1e-4.
  • CHARMM/mlpot ML-only calculator (mmml/interfaces/pycharmmInterface/mlpot/derivative_test.py, saved result at artifacts/pycharmm_mlpot/mlpot_force_fd.json) — currently FAILS all 60 checked force components against tol=0.005 kcal/mol/Å, with a max discrepancy of 1.52 kcal/mol/Å and rms 0.75. This is real, current state, not historical — worth fixing before trusting forces from that integration path.

finite-difference parity and residuals

The per-atom breakdown of the failing check shows the discrepancy spread roughly evenly across all 20 atoms rather than concentrated on a few — that pattern is more consistent with a systematic force-term issue (a missing or mis-signed contribution present at every atom) than a boundary/indexing bug that would only hit a handful of atoms:

per-atom max discrepancy

How to pick

from mmml.utils.plotting.styles import apply_plot_style

apply_plot_style("icml")  # or editorial_dejavu_serif / _dejavu_sans / _stix / _cm

_STYLE_NAME in workflows/*/scripts/plot_results.py and plot_structure.py is currently "icml" — see docs/plotting-style-guide.md for the rest of the convention (semantic color, DPI, uncertainty shading, legends outside).

(There was a 5th candidate, editorial_stixsans, dropped from the gallery: matplotlib has no real "STIX Sans" font bundled, so it silently fell back to the same rendering as editorial_dejavu_sans — not a meaningfully distinct option.)