Ask how to store manufacturing test results and the most common answer you will find in forums is that there is no industry standard. That answer is half right. There is no single standard covering board-level functional test the way STDF covers semiconductor test. But there are well-understood approaches, each with a known point at which it stops working.
This guide covers four of them in the order teams usually encounter them, and says plainly where each one breaks.
What You Actually Need to Store
Before choosing a format, be clear about the entities. Almost every team converges on the same five:
| Entity | What it is |
|---|---|
| Unit | A physical thing with a serial number |
| Run | One execution of a test procedure against one unit |
| Step | One phase within a run, with its own pass or fail |
| Measurement | A named value with units and limits |
| Procedure | The versioned definition of what was tested |
The two that get missed early and hurt later are procedure version and sub-assembly relationships. If you cannot answer "which version of the test produced this result" or "which PCBA went into this final assembly", you will rebuild your schema.
Approach 1: Flat Files
CSV or JSON written to a network share, one file per run.
import csvfrom datetime import datetime, timezonedef log_run(serial, procedure, measurements, passed): stamp = datetime.now(timezone.utc).isoformat() path = f"//test-share/results/{serial}_{stamp}.csv" with open(path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["serial", "procedure", "measurement", "value", "unit", "low", "high", "passed"]) for m in measurements: writer.writerow([ serial, procedure, m["name"], m["value"], m["unit"], m["low"], m["high"], m["passed"], ])Works until: you need to answer a question across files. First-pass yield for last month means parsing thousands of files. A schema change means old files no longer parse the same way. Two stations writing at once eventually corrupt something. Nobody can find the file for a specific serial without a naming convention that itself becomes a schema.
Realistic ceiling: one station, low volume, or a genuinely temporary setup.
Approach 2: A SQL Schema You Own
The natural next step, and the right answer for many teams.
CREATE TABLE unit ( id BIGSERIAL PRIMARY KEY, serial_number TEXT NOT NULL UNIQUE, part_number TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE run ( id BIGSERIAL PRIMARY KEY, unit_id BIGINT NOT NULL REFERENCES unit(id), procedure_id TEXT NOT NULL, procedure_version TEXT NOT NULL, station_id TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL, duration_s NUMERIC NOT NULL, passed BOOLEAN NOT NULL);CREATE TABLE measurement ( id BIGSERIAL PRIMARY KEY, run_id BIGINT NOT NULL REFERENCES run(id) ON DELETE CASCADE, step_name TEXT NOT NULL, name TEXT NOT NULL, value DOUBLE PRECISION, unit TEXT, lower_limit DOUBLE PRECISION, upper_limit DOUBLE PRECISION, passed BOOLEAN NOT NULL);CREATE INDEX ON run (procedure_id, started_at DESC);CREATE INDEX ON measurement (run_id);CREATE INDEX ON measurement (name, run_id);First-pass yield then becomes a query rather than a script:
SELECT date_trunc('day', first_run.started_at) AS day, count(*) FILTER (WHERE first_run.passed) * 100.0 / count(*) AS fpy_percent, count(*) AS units_testedFROM ( SELECT DISTINCT ON (unit_id) unit_id, started_at, passed FROM run WHERE procedure_id = 'FCT-001' ORDER BY unit_id, started_at ASC) AS first_runGROUP BY dayORDER BY day DESC;Note the DISTINCT ON and the ascending sort. First-pass yield means the first run per unit, not any passing run. Getting this wrong is the single most common reporting bug in home-built systems, and it always inflates the number.
Works until: the analytics backlog grows faster than you can clear it. Cpk, control charts with proper limits, Pareto of failure modes, drift detection, per-station comparison, and operator-facing reports are each a small project. Then someone asks for the same numbers across three sites with intermittent connectivity.
Realistic ceiling: healthy for a long time if someone owns it. The cost is ongoing engineering, not licensing.
Approach 3: Industry Standards
Two standards matter, and neither covers everything.
STDF (Standard Test Data Format) is the semiconductor standard from Teradyne, now widely supported by ATE. It is binary, compact, and assumes wafers, dies, and bins. If you are testing silicon, use it. If you are testing assembled boards, its data model does not fit and forcing it costs more than it saves.
ATML (Automatic Test Markup Language) is the IEEE 1671 family, XML-based, and designed for test description and results interchange, largely in aerospace and defense. It is thorough and verbose. It is most useful when a contract requires it.
There is also WSJF and WSXF, which are WATS-specific rather than open standards, and worth knowing about mainly because migrating off WATS means dealing with them.
The honest summary: for board-level functional test, no standard has achieved the adoption STDF has in semiconductors. Most teams end up with a proprietary schema, whether their own or a vendor's.
Approach 4: A Test Data Platform
Use a system built for this, so the schema and the analytics come with it.
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures( htf.Measurement("rail_3v3") .in_range(3.2, 3.4) .with_units(units.VOLT), htf.Measurement("current_draw") .in_range(0.1, 0.5) .with_units(units.AMPERE),)def power_rail_test(test): test.measurements.rail_3v3 = 3.31 test.measurements.current_draw = 0.24def main(): test = htf.Test( power_rail_test, procedure_id="FCT-001", part_number="PCBA-200", ) test.add_output_callbacks(upload()) test.execute(test_start=lambda: input("Scan serial: "))if __name__ == "__main__": main()The measurement definition carries the name, limits, and units, so storage and pass/fail evaluation follow from the test code rather than being maintained separately. Yield, Cpk, control charts, and failure Pareto are computed rather than queried.
Options in this space include WATS, which is the long-standing incumbent, and TofuPilot. Both handle the entities above. They differ mainly in deployment model, licensing shape, and whether test code lives in your Git repository.
Works until: you have a requirement genuinely outside the product's model. At that point you are back to Approach 2, ideally with the vendor's export.
Choosing
| Situation | Approach |
|---|---|
| One station, prototype volume | Flat files, but plan the exit |
| Multiple stations, engineer available to own it | SQL schema |
| Semiconductor test | STDF |
| Contract mandates a format | ATML or whatever the contract says |
| Board-level test, want analytics without building them | Test data platform |
Mistakes That Cost the Most
Not versioning the procedure. When limits change, old results become uninterpretable unless you recorded which version produced them.
Storing pass or fail without the value. A boolean tells you a unit failed. The measured value tells you it was drifting for three weeks beforehand.
Ignoring sub-assemblies. Serial number traceability from final assembly down to component boards is retrofitted at great cost. Model it on day one even if you do not use it yet.
Computing yield from any passing run. First-pass yield is the first run per unit. Counting retests inflates it, sometimes dramatically.
No timezone discipline. Store UTC. Three sites in three timezones will otherwise disagree about which day a failure occurred.