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_idstring, uniquePYRO_DRV_STDInstance name and output folder
actuation_typeenumpyrotechnicSelects the template branch
stroke_mmfloat > 0180.0Total pull-in stroke
pull_in_time_msfloat > 012.5Time to reach stroke plateau
force_plateau_Nfloat > 03200Retraction force plateau
ll_threshold_Nfloat > 04500Load limiter force threshold
ll_stroke_limit_mmfloat > 095.0Load limiter stroke limit
templatepathtpl/pretensioner.moModelica 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.

Configuration Spreadsheet → Variant Matrix variants.csv variant_id actuation_type stroke_mm pull_in_time_ms force_plateau_N ll_threshold_N Validate unique id · enum · numeric all values > 0 required columns present Abort build row + column reported pass Render template pretensioner.mo $placeholder → value one file per row PYRO_DRV_STD pyrotechnic · 180 mm MECH_PAS_STD mechanical · 150 mm MOTOR_REAR_STD motorized · 210 mm manifest.json id · type · file path Branch semantics Validation gate — any error aborts before file writes One instance per row, folder named by variant_id Failure path reports row index and offending column

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:

  1. 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.
  2. Spot-check a rendered file. Open one .mo and confirm the substituted values match the source row exactly — e.g. stroke_mm = 180.0, not 180 or a leftover $stroke_mm.
  3. Negative test. Temporarily set one row's actuation_type to pyrotecnhic and rerun. The generator must exit with a message naming that row and column, and no files should be written for that run.
A common failure symptom is a generated model that builds cleanly but has a parameter stuck at its template default. That almost always means a placeholder name in the template does not match the key passed to 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.
Keep the template free of architecture-specific conditionals. If pyrotechnic and motorized variants need different internal structure, use separate template files and let the 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_idactuation_typestroke_mmpull_in_time_msforce_plateau_Nll_threshold_Nll_stroke_limit_mmoutput file
PYRO_DRV_STDpyrotechnic180.012.53200450095.0build/models/PYRO_DRV_STD/PYRO_DRV_STD.mo
MECH_PAS_STDmechanical150.018.02800400085.0build/models/MECH_PAS_STD/MECH_PAS_STD.mo
MOTOR_REAR_STDmotorized210.022.036005000110.0build/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.