4. Generate Pretensioner Variant Matrix from Configuration Spreadsheet
Why a Variant Matrix Instead of Hand-Built Models
A pretensioner family typically ships in three actuation architectures — pyrotechnic, mechanical (spring/ball), and motorized — and each architecture fans out into several calibration variants that differ in stroke length, pull-in time, force plateau, and load-limiter threshold. Building those instances by hand is where configuration drift starts: a copy-paste error in one stroke_mm value silently produces a model that passes the build but fails correlation. The task here is to treat the spreadsheet as the single source of truth and let a generator emit one model instance per row, each with its own parameter set, so that the variant count is a property of the data rather than of someone's patience.
Spreadsheet Contract
The generator is only as reliable as the column contract it enforces. Use a flat sheet named variants with one row per model instance and a header row. The columns below are the minimum needed to produce a buildable instance; extra columns are carried through as metadata but never silently dropped.
| Column | Type | Example | Role |
|---|---|---|---|
variant_id | string, unique | PYRO_DRV_STD | Instance name and output folder |
actuation_type | enum | pyrotechnic | Selects the template branch |
stroke_mm | float > 0 | 180.0 | Total pull-in stroke |
pull_in_time_ms | float > 0 | 12.5 | Time to reach stroke plateau |
force_plateau_N | float > 0 | 3200 | Retraction force plateau |
ll_threshold_N | float > 0 | 4500 | Load limiter force threshold |
ll_stroke_limit_mm | float > 0 | 95.0 | Load limiter stroke limit |
template | path | tpl/pretensioner.mo | Modelica template to instantiate |
Two contract rules matter more than the rest. First, variant_id must be unique and filesystem-safe, because it becomes a directory name. Second, actuation_type must be one of the three known values; an unknown value is a hard error, not a warning, since a typo like pyrotecnhic would otherwise fall through to a default branch and produce a plausible but wrong model.
Generator Implementation
The generator reads the sheet with openpyxl, validates each row, renders the template with string.Template, and writes one .mo file per variant plus a manifest. Keeping the template as plain text with $placeholder tokens avoids pulling in a full templating engine and keeps the generated files diff-friendly.
import csv, json, re
from pathlib import Path
from string import Template
REQUIRED = ["variant_id", "actuation_type", "stroke_mm", "pull_in_time_ms",
"force_plateau_N", "ll_threshold_N", "ll_stroke_limit_mm", "template"]
ACTUATION = {"pyrotechnic", "mechanical", "motorized"}
ID_RE = re.compile(r"^[A-Za-z0-9_]+$")
def load_rows(csv_path):
with open(csv_path, newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
missing = [c for c in REQUIRED if c not in reader.fieldnames]
if missing:
raise ValueError(f"missing columns: {missing}")
return list(reader)
def validate(rows):
errors, seen = [], set()
for i, r in enumerate(rows, start=2): # row 1 is header
vid = r["variant_id"].strip()
if not ID_RE.match(vid):
errors.append(f"row {i}: invalid variant_id '{vid}'")
if vid in seen:
errors.append(f"row {i}: duplicate variant_id '{vid}'")
seen.add(vid)
if r["actuation_type"] not in ACTUATION:
errors.append(f"row {i}: unknown actuation_type '{r['actuation_type']}'")
for col in ("stroke_mm", "pull_in_time_ms", "force_plateau_N",
"ll_threshold_N", "ll_stroke_limit_mm"):
try:
if float(r[col]) <= 0:
errors.append(f"row {i}: {col} must be > 0")
except ValueError:
errors.append(f"row {i}: {col} not numeric ('{r[col]}')")
return errors
def render(row, tpl_text):
tpl = Template(tpl_text)
return tpl.substitute(
variant_id=row["variant_id"],
actuation_type=row["actuation_type"],
stroke_mm=float(row["stroke_mm"]),
pull_in_time_ms=float(row["pull_in_time_ms"]),
force_plateau_N=float(row["force_plateau_N"]),
ll_threshold_N=float(row["ll_threshold_N"]),
ll_stroke_limit_mm=float(row["ll_stroke_limit_mm"]),
)
def generate(csv_path, out_dir):
rows = load_rows(csv_path)
errors = validate(rows)
if errors:
raise SystemExit("validation failed:\n" + "\n".join(errors))
out = Path(out_dir)
manifest = []
for r in rows:
tpl_text = Path(r["template"]).read_text(encoding="utf-8")
body = render(r, tpl_text)
target = out / r["variant_id"] / f"{r['variant_id']}.mo"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
manifest.append({"variant_id": r["variant_id"],
"actuation_type": r["actuation_type"],
"file": str(target)})
(out / "manifest.json").write_text(json.dumps(manifest, indent=2))
return manifest
if __name__ == "__main__":
generate("config/pretensioner_variants.csv", "build/models")
The template itself carries the architecture-specific structure, so the generator stays agnostic to whether a variant is pyrotechnic or motorized:
model ${variant_id}
// actuation: ${actuation_type}
parameter Real stroke_mm = ${stroke_mm};
parameter Real pull_in_time_ms = ${pull_in_time_ms};
parameter Real force_plateau_N = ${force_plateau_N};
parameter Real ll_threshold_N = ${ll_threshold_N};
parameter Real ll_stroke_limit_mm = ${ll_stroke_limit_mm};
PretensionerCore core(
stroke_mm = stroke_mm,
pull_in_time_ms = pull_in_time_ms,
force_plateau_N = force_plateau_N);
LoadLimiter ll(
threshold_N = ll_threshold_N,
stroke_limit_mm = ll_stroke_limit_mm);
end ${variant_id};
What the Matrix Looks Like
The diagram below shows the data flow from the spreadsheet through validation into per-variant instances, with the three actuation branches and the failure gate that stops a bad row before any file is written.
Running and Verifying
With the CSV and template in place, run python generate_variants.py. A clean run produces build/models/<variant_id>/<variant_id>.mo for every row and a manifest.json whose entry count equals the data-row count. Verify in three steps:
- Count check.
len(manifest) == number of data rows. A mismatch means a row was skipped, usually because a blank line was parsed as a record. - Spot-check a rendered file. Open one
.moand confirm the substituted values match the source row exactly — e.g.stroke_mm = 180.0, not180or a leftover$stroke_mm. - Negative test. Temporarily set one row's
actuation_typetopyrotecnhicand rerun. The generator must exit with a message naming that row and column, and no files should be written for that run.
Template.substitute — substitute raises KeyError on a missing key, but a typo in the template (e.g. $stroke instead of $stroke_mm) will raise only if that token is actually present. Grep the template for \$ and reconcile every token against the render() call before trusting the output.template column select among them — this keeps each template readable and makes the variant matrix a pure data concern.Deliverable: Variant Matrix Register
The manifest is the artifact to hand to the build team. A minimal register derived from it looks like this:
| variant_id | actuation_type | stroke_mm | pull_in_time_ms | force_plateau_N | ll_threshold_N | ll_stroke_limit_mm | output file |
|---|---|---|---|---|---|---|---|
| PYRO_DRV_STD | pyrotechnic | 180.0 | 12.5 | 3200 | 4500 | 95.0 | build/models/PYRO_DRV_STD/PYRO_DRV_STD.mo |
| MECH_PAS_STD | mechanical | 150.0 | 18.0 | 2800 | 4000 | 85.0 | build/models/MECH_PAS_STD/MECH_PAS_STD.mo |
| MOTOR_REAR_STD | motorized | 210.0 | 22.0 | 3600 | 5000 | 110.0 | build/models/MOTOR_REAR_STD/MOTOR_REAR_STD.mo |
These values are illustrative example parameters, not measured data. The register is complete when every row in the source spreadsheet appears exactly once, every output file path exists on disk, and the negative test above has been confirmed to abort cleanly. At that point the variant matrix is reproducible from the spreadsheet alone, and adding a new calibration is a matter of appending a row rather than editing model files by hand.