Skip to content

07 · A plot is an argument

A plotting library can render almost anything. Scientific judgement determines what should be rendered and what conclusion the figure can support.

Decision: which visual encoding tests the stated claim?

Output: a deterministic figure backed by reduced data.

Frame

Suppose the claim is:

kinetic energy approaches a plateau while the minimum length scale continues to shrink.

The figure needs both quantities against the same time coordinate. Their scales may differ by orders of magnitude, so two aligned panels are clearer than two unlabelled axes overlaid.

Predict

Before plotting:

  • Which axis is independent?
  • Are quantities dimensional or nondimensional?
  • Which trends should be monotonic?
  • Is zero possible on a log axis?
  • What visual comparison carries the claim?
  • What data must remain available behind the figure?

Start with the claim

figure, axes = plt.subplots(2, 1, sharex=True)
axes[0].plot(time, kinetic_energy)
axes[1].plot(time, minimum_length)

Then make meaning explicit:

axes[0].set_ylabel("kinetic energy")
axes[1].set_xlabel("time")
axes[1].set_ylabel(r"$h_{\min}$")

If values are dimensionless, say so in the caption or symbol definition. If they have units, include them.

Linear, log, and normalised scales

Use a log scale when ratios and power laws are the comparison. Do not use it to make a weak trend look dramatic.

On log axes:

  • values must be positive;
  • multiplicative spacing is visible;
  • a straight line suggests a power law only over the shown range;
  • fitted exponents need a declared window and uncertainty.

Normalisation is a physical decision:

\[ \hat{t} = t/t_\sigma. \]

Name the scale and justify it. “Nondimensional” is not enough.

Encode categories accessibly

For a regime map, use both colour and marker:

styles = {
    "no-jet": ("x", blue),
    "one-drop": ("o", gold),
    "multiple-drops": ("^", coral),
}

The figure remains interpretable when printed in greyscale or viewed with colour-vision deficiency. Direct labels often beat a distant legend.

Defaults are not neutral

Check:

  • axis limits;
  • aspect ratio;
  • tick precision;
  • legend order;
  • line and marker overlap;
  • font size at final publication width;
  • whether a colour map is sequential, diverging, or categorical.

Never connect unordered categories with a line. Never draw a smooth regime boundary simply because an interpolator is available.

Save deterministically

figure.savefig(output, dpi=180, bbox_inches="tight")
plt.close(figure)

A shared script should save an output and exit. plt.show() is useful during exploration but fails on headless batch machines and cannot serve as a build contract.

Keep figure creation inside a function so tests can check that an output is written and source arrays remain unchanged.

The course reference functions are intentionally modest:

"""Deterministic figures for the teaching workflows."""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.axes import Axes

from .io import RegimeCase, SimulationLog

INK = "#13201f"
TEAL = "#0b5d5b"
BLUE = "#2f6f9f"
CORAL = "#e45d3f"
GOLD = "#c3912c"


def _output_path(path: str | Path) -> Path:
    output = Path(path)
    output.parent.mkdir(parents=True, exist_ok=True)
    return output


def plot_log(log: SimulationLog, output: str | Path) -> Path:
    """Save a two-panel time-series figure from a validated log."""

    destination = _output_path(output)
    figure, axes = plt.subplots(2, 1, figsize=(6.4, 5.4), sharex=True)

    axes[0].plot(log.time, log.kinetic_energy, color=TEAL, linewidth=2)
    axes[0].set_ylabel("kinetic energy")
    axes[0].set_yscale("log")

    axes[1].plot(log.time, log.minimum_length, color=CORAL, linewidth=2)
    axes[1].set_xlabel("time")
    axes[1].set_ylabel(r"$h_{\min}$")
    axes[1].set_yscale("log")

    for axis in axes:
        axis.spines[["top", "right"]].set_visible(False)
        axis.grid(alpha=0.2, linewidth=0.6)

    figure.suptitle("Synthetic Basilisk log", color=INK)
    figure.tight_layout()
    figure.savefig(destination, dpi=180, bbox_inches="tight")
    plt.close(figure)
    return destination


def _draw_regime_map(cases: list[RegimeCase], axis: Axes) -> None:
    """Draw validated categorical cases on an existing axis."""

    if not cases:
        raise ValueError("at least one case is required")
    styles = {
        "no-jet": ("x", BLUE),
        "one-drop": ("o", GOLD),
        "multiple-drops": ("^", CORAL),
    }

    for outcome, (marker, colour) in styles.items():
        selected = [case for case in cases if case.outcome == outcome]
        if not selected:
            continue
        axis.scatter(
            [case.ohnesorge for case in selected],
            [case.bond for case in selected],
            marker=marker,
            color=colour,
            label=outcome.replace("-", " "),
            s=58,
            linewidths=1.4,
        )

    axis.set_xscale("log")
    axis.set_yscale("log")
    axis.set_xlabel(r"Ohnesorge number, $Oh$")
    axis.set_ylabel(r"Bond number, $Bo$")
    axis.spines[["top", "right"]].set_visible(False)
    axis.grid(which="both", alpha=0.18, linewidth=0.6)
    axis.legend(frameon=False, title="observed outcome")


def plot_regime_map(cases: list[RegimeCase], output: str | Path) -> Path:
    """Save a labelled Oh–Bo regime map."""

    destination = _output_path(output)
    figure, axis = plt.subplots(figsize=(6.4, 4.8))
    _draw_regime_map(cases, axis)
    figure.tight_layout()
    figure.savefig(destination, dpi=180, bbox_inches="tight")
    plt.close(figure)
    return destination
Two aligned logarithmic panels show kinetic energy rising to a plateau while minimum length decreases with time.
The shared time axis supports the comparison; separate panels avoid a misleading dual-axis overlay. Synthetic teaching data.
A logarithmic Ohnesorge–Bond regime map distinguishes no jet, one drop, and multiple drops using colour and marker shape.
Colour and marker shape carry the categories together; no interpolated boundary is invented between sparse cases.

A figure is not the data

Ship the reduced table used to draw it. A reader should be able to:

  • inspect exact values;
  • change an axis choice;
  • test another normalisation;
  • reproduce the figure without raw simulation dumps.

The public Soft-Matter-Singularities-paper-figures repository is a strong example of making figure jobs, commands, and expected outputs explicit.

Verify

Complete Exercise 07, then run the two reference commands:

uv run comphy-python101 plot-log data/basilisk_log.csv \
  --output build/log.png
uv run comphy-python101 plot-regime data/regime_map.csv \
  --output build/regime-map.png

Inspect both at their final display size. Then remove colour temporarily. Do categories and hierarchy survive?

Reflect

Write the claim of your last scientific figure in one sentence. For every visual element, ask whether it supplies evidence, orientation, or decoration. Decoration is allowed. Confusion is not.