2. Build a Repeatable Curve Overlay Script for Multiple Impact Simulations

Why a repeatable overlay script matters

Design comparison across impact simulations fails for a mundane reason: every analyst rebuilds the same plot by hand, and each rebuild drifts. One run uses a 0–120 ms window, the next 0–150 ms; one legend says LC01, another Loadcase_1; one curve is smoothed, another is not. The result is a set of figures that cannot be compared side by side, and a review meeting spent arguing about styling instead of injury severity.

The fix is a small, parameterized Python script that owns three responsibilities: discovery (find result files in a directory), extraction (pull named injury channels onto a common time base), and presentation (apply one styling contract, one legend scheme, and one set of axis limits). The script is the single source of truth for how a comparison figure looks, so any analyst who runs it gets a byte-comparable plot.

Input contract: what the script expects

The script should not guess. Define a small, explicit input contract and validate it before plotting. For this example we assume each simulation writes one CSV per load case into a results directory, with a header row and a time column in milliseconds:

time_ms,head_accel_g,chest_defl_mm,chest_accel_g
0.000,0.00,0.00,0.00
0.500,1.24,0.31,0.88
1.000,4.87,1.02,3.41
...

Two configuration objects drive everything: a ChannelSpec describing which column to plot and how to label it, and a PlotSpec describing axis limits, styling, and output. Keeping these as data (not hard-coded literals inside the plotting call) is what makes the script reusable across the 30-case series.

Channel and plot specification

The channel spec maps a physical quantity to a column name, a unit, and a display label. The plot spec fixes the comparison frame so that every figure in the series shares the same axes:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class ChannelSpec:
    key: str            # column name in the CSV
    label: str          # legend / axis label
    unit: str
    scale: float = 1.0  # unit conversion applied on load

@dataclass(frozen=True)
class PlotSpec:
    xlim: tuple = (0.0, 120.0)      # ms
    ylim: tuple = (-5.0, 90.0)      # g, for head acceleration
    xlabel: str = "Time [ms]"
    ylabel: str = "Head acceleration [g]"
    title: str = "Head acceleration overlay"
    figsize: tuple = (9.0, 5.0)
    dpi: int = 150
    line_width: float = 1.6
    palette: tuple = (
        "#1f77b4", "#2ca02c", "#ff7f0e", "#9467bd",
        "#17becf", "#d62728", "#8c564b", "#7f7f7f",
    )
    out_path: str = "overlay_head_accel.png"

Note the deliberate choices: xlim and ylim are fixed, not auto-scaled, because auto-scaling is the single biggest source of "these plots look different" confusion. The palette is a fixed tuple so that load case n always receives the same color regardless of file ordering — provided we sort the file list deterministically.

Discovery, extraction, and deterministic ordering

Discovery must be sorted. os.listdir returns filesystem order, which varies between machines and runs; sorting by filename gives a stable mapping from load case to color and legend entry.

import csv
from pathlib import Path

def discover(result_dir: str, pattern: str = "*.csv") -> list[Path]:
    files = sorted(Path(result_dir).glob(pattern))
    if not files:
        raise FileNotFoundError(f"No files matching {pattern} in {result_dir}")
    return files

def extract(path: Path, channel: ChannelSpec) -> tuple[list[float], list[float]]:
    t, y = [], []
    with path.open(newline="") as fh:
        reader = csv.DictReader(fh)
        if channel.key not in reader.fieldnames:
            raise KeyError(f"{path.name}: missing column '{channel.key}'")
        for row in reader:
            t.append(float(row["time_ms"]))
            y.append(float(row[channel.key]) * channel.scale)
    return t, y

The KeyError on a missing column is intentional: a silent skip would produce a figure that quietly omits a load case, which is worse than a hard failure. The scale factor handles unit normalization (for example, converting m/s² to g with scale=1/9.80665) without touching the source data.

Overlay rendering with a single styling contract

The renderer consumes the specs and the extracted series. Every visual decision — line width, color assignment, legend placement, axis limits, grid — is applied here and nowhere else:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

def render_overlay(series: dict[str, tuple[list, list]],
                   spec: PlotSpec) -> None:
    fig, ax = plt.subplots(figsize=spec.figsize, dpi=spec.dpi)
    for i, (name, (t, y)) in enumerate(series.items()):
        ax.plot(t, y, lw=spec.line_width,
                color=spec.palette[i % len(spec.palette)],
                label=name)
    ax.set_xlim(*spec.xlim)
    ax.set_ylim(*spec.ylim)
    ax.set_xlabel(spec.xlabel)
    ax.set_ylabel(spec.ylabel)
    ax.set_title(spec.title)
    ax.grid(True, which="major", alpha=0.3)
    ax.legend(loc="upper right", frameon=False, fontsize=9)
    fig.tight_layout()
    fig.savefig(spec.out_path)
    plt.close(fig)

Because series is a plain dict built from the sorted file list, the color index i is stable: LC01 is always blue, LC02 always green, and so on. If a load case is added later, it appends at the end of the sorted order and takes the next palette slot without disturbing existing assignments.

Wiring it together

The entry point is deliberately thin — it composes the three stages and nothing more:

def build_overlay(result_dir: str,
                  channel: ChannelSpec,
                  spec: PlotSpec) -> None:
    files = discover(result_dir)
    series = {}
    for path in files:
        t, y = extract(path, channel)
        series[path.stem] = (t, y)
    render_overlay(series, spec)

if __name__ == "__main__":
    head = ChannelSpec(key="head_accel_g",
                       label="Head acceleration",
                       unit="g")
    plot = PlotSpec()
    build_overlay("results/", head, plot)

Running this against a directory of eight load-case CSVs produces overlay_head_accel.png with eight curves, a fixed 0–120 ms window, a fixed −5 to 90 g range, and a legend keyed by filename stem. To produce the chest-deflection figure, change only the ChannelSpec and the PlotSpec axis labels and limits — the discovery, extraction, and rendering logic are untouched.

Repeatable overlay pipeline: one styling contract, many load cases 1 · Discover results/*.csv sorted() → stable order LC01 … LC08 empty dir → FileNotFoundError 2 · Extract ChannelSpec.key time_ms + head_accel_g × scale (unit normalize) missing column → KeyError 3 · Render PlotSpec: xlim/ylim palette[i % len] legend = filename stem fixed axes, no autoscale overlay.png ChannelSpec key · label · unit · scale frozen dataclass PlotSpec xlim · ylim · palette · dpi frozen dataclass Failure symptoms → cause Colors shift between runs → unsorted file list Curve missing silently → column name mismatch Axes differ across figures → autoscale left on Legend order wrong → dict built from unsorted glob Verification checklist Re-run twice → identical PNG (deterministic) LC01 always palette[0] regardless of order xlim/ylim match PlotSpec exactly Legend count == number of CSVs discovered

Verification and failure triage

A repeatable script is only trustworthy if you can prove it repeats. Run the script twice against the same directory and compare the output files — with a fixed dpi, fixed figsize, and no timestamp in the title, the PNGs should be identical. If they differ, the usual culprit is an unsorted file list feeding the palette index.

The most common failure modes and their fixes:

  • Colors shift between runs. Path.glob order is not guaranteed across filesystems. The sorted() in discover is not optional.
  • A curve is silently missing. A column name typo in ChannelSpec.key raises KeyError — good. But a filename pattern that misses a file (e.g. *.csv vs *.CSV) drops it silently. Assert that the discovered count matches the expected load-case count.
  • Axes differ across figures. Someone re-enabled autoscaling or passed a different PlotSpec. Keep PlotSpec frozen and shared across the series.
  • Legend order looks wrong. The dict was built from an unsorted glob. Sort before building series.
Treat PlotSpec as the series-wide style guide. When a reviewer asks for a wider time window, change xlim in one place and regenerate every figure — that is the entire point of the exercise.
Do not apply smoothing, filtering, or resampling inside this script. Those belong to the data-standardization stage; mixing them into the renderer makes the overlay non-reproducible and hides preprocessing decisions from the comparison.

Deliverable: overlay configuration sheet

Fill this in once per comparison campaign and commit it alongside the script so the styling contract is auditable:

FieldExample valuePurpose
result_dirresults/Directory scanned for load-case CSVs
pattern*.csvGlob filter; keep case-consistent
ChannelSpec.keyhead_accel_gColumn extracted from each file
ChannelSpec.scale1.0Unit normalization factor
PlotSpec.xlim(0, 120) msFixed comparison window
PlotSpec.ylim(-5, 90) gFixed comparison range
PlotSpec.palette8-color tupleStable load-case → color mapping
PlotSpec.out_pathoverlay_head_accel.pngDeterministic output name

With the script and this sheet in place, adding a ninth load case is a matter of dropping a CSV into results/ and re-running — the new curve inherits the next palette color, the legend extends by one entry, and every existing curve keeps its styling. That is the repeatability the rest of the 30-case series depends on.