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.
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.globorder is not guaranteed across filesystems. Thesorted()indiscoveris not optional. - A curve is silently missing. A column name typo in
ChannelSpec.keyraisesKeyError— good. But a filename pattern that misses a file (e.g.*.csvvs*.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. KeepPlotSpecfrozen and shared across the series. - Legend order looks wrong. The dict was built from an unsorted glob. Sort before building
series.
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.Deliverable: overlay configuration sheet
Fill this in once per comparison campaign and commit it alongside the script so the styling contract is auditable:
| Field | Example value | Purpose |
|---|---|---|
result_dir | results/ | Directory scanned for load-case CSVs |
pattern | *.csv | Glob filter; keep case-consistent |
ChannelSpec.key | head_accel_g | Column extracted from each file |
ChannelSpec.scale | 1.0 | Unit normalization factor |
PlotSpec.xlim | (0, 120) ms | Fixed comparison window |
PlotSpec.ylim | (-5, 90) g | Fixed comparison range |
PlotSpec.palette | 8-color tuple | Stable load-case → color mapping |
PlotSpec.out_path | overlay_head_accel.png | Deterministic 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.