Test Data & Analytics

How to Detect Measurement Drift

Detect measurement drift before it becomes scrap: what drift looks like in test data, which signals to watch, and how to catch it early.

JJulien Buteau
intermediate8 min readMarch 14, 2026

Drift is a measurement moving slowly in one direction while still passing. It is the most valuable signal in test data and the easiest to miss, because every unit passes right up until the day they do not.

What Drift Looks Like

PatternTypical cause
Gradual shift in the meanStencil wear, instrument out of calibration, supplier change
Widening spread, stable meanFixture contact degrading, a second population appearing
Step changeComponent lot change, firmware release, limit change
SawtoothSomething reset periodically: recalibration, shift change
Drift on one station onlyFixture, cabling, or that station's instrument
Drift correlated with time of dayAmbient temperature, mains loading

The first is the dangerous one. A rail drifting from 3.30 V toward a 3.40 V limit produces perfect yield for weeks and then a bad afternoon.

Why Pass/Fail Cannot See It

If your records store only verdicts, drift is invisible by construction. Every run says PASS until the day the limit is crossed, and then yield collapses with no warning and no history to diagnose from.

Detecting drift requires the measured values.

procedure.yaml
24 lines
name: PCBA Functional Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: dmm    python: plugs.dmm:Multimetermain:  - name: Power Rails    python: phases.power_rails    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4
phases/power_rails.py
def power_rails(measurements, dmm):    measurements.rail_3v3 = dmm.dc_voltage()
plugs/dmm.py
class Multimeter:    def dc_voltage(self) -> float:        return 3.31  # Replace with a real instrument read

Every run stores the number with its limits, so the distribution over time exists whether or not anyone is looking at it yet.

Record the Context That Explains Drift

A drift you can see but not attribute is only half useful. The metadata that makes attribution possible costs almost nothing:

phases/record_context.py
def record_context(run, dmm):    run.metadata["station"] = "FCT-03"    run.metadata["fixture_id"] = "FIX-4417-02"    run.metadata["dmm_id"] = dmm.identify()    run.metadata["operator_shift"] = "B"

With those, "the mean shifted on 14 March" becomes "the mean shifted on FCT-03 only, after the fixture was swapped", which is an actionable statement.

The Signals Worth Watching

Mean shift against the limit. The distance from the process mean to the nearest limit, in standard deviations, is exactly what Cpk measures. A Cpk falling from 1.8 to 1.3 with no failures yet is the clearest early warning available.

Western Electric rules on a control chart. Seven consecutive points on one side of the centre line, or a run of consistently increasing values, both indicate drift long before anything exceeds a limit. See how to use control charts.

Spread changes. A standard deviation that doubles while the mean holds usually means a fixture or contact problem, not a product change.

Bimodality. Two humps in a histogram mean two populations mixed. Split by station, fixture or lot; the drift is usually confined to one of them.

Checking It Yourself

If you want a scheduled check rather than eyes on a chart:

analysis/drift.py
31 lines
import statisticsdef mean_shift_sigma(baseline: list[float], recent: list[float]) -> float:    """How far the recent mean has moved, in baseline standard deviations."""    sigma = statistics.stdev(baseline)    if sigma == 0:        raise ValueError("Zero variation in baseline; check resolution")    return (statistics.mean(recent) - statistics.mean(baseline)) / sigmadef trend_run_length(values: list[float]) -> int:    """Longest run of consecutive increases or decreases."""    longest = current = 1    direction = 0    for a, b in zip(values, values[1:]):        step = (b > a) - (b < a)        if step != 0 and step == direction:            current += 1            longest = max(longest, current)        else:            direction = step            current = 1    return longestbaseline = [3.30, 3.31, 3.29, 3.31, 3.30, 3.32, 3.30]recent = [3.35, 3.36, 3.35, 3.37, 3.36]print(f"shift: {mean_shift_sigma(baseline, recent):.1f} sigma")print(f"longest run: {trend_run_length(baseline + recent)}")

A shift beyond two sigma, or a run of seven or more, is worth investigating even with a perfect pass rate.

Two Traps in the Analysis

A limit change looks exactly like a process change. Tighten a limit and yield drops with no drift at all. This is why version on the procedure matters: without it, weeks get spent looking for a process cause that does not exist.

A renamed measurement looks like drift stopping. Renaming splits the history in two, and the old series simply ends. Treat measurement names as permanent.

Responding to Drift

ObservationLikely causeAction
Slow mean shift, all stationsSupplier, process, or environmentalCheck build records and component lots
Shift on one stationFixture, cabling, instrumentCompare against another station; check calibration
Spread wideningContact degradationMeasure contact resistance; service the fixture
Step change at a known dateLot, firmware, or limit changeCheck the procedure version first
SawtoothSomething is being resetCorrelate with calibration or shift schedule
BimodalTwo populationsSplit by station, fixture, lot

Checking the procedure version first is the cheapest step and rules out the most embarrassing cause.

Instrument Drift vs Product Drift

A drifting measurement may be the instrument, not the product. The distinguishing test is a known-good reference unit: run it periodically and record its measurements like any other unit.

procedure.yaml
- name: Golden Unit Check    python: phases.golden_unit    measurements:      - name: Golden Rail 3V3        unit: V        description: Reference unit; drift here indicates the instrument        validators:          - operator: ">="            expected_value: 3.28          - operator: "<="            expected_value: 3.34

If the golden unit drifts, the bench is the problem. If it holds steady while production drifts, the product or process is.

If You Already Run OpenHTF

fct.py
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),)def phase_power(test):    test.measurements.rail_3v3 = 3.31    test.metadata["station"] = "FCT-03"    test.metadata["fixture_id"] = "FIX-4417-02"test = htf.Test(phase_power, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

Install with pip install "tofupilot[openhtf]", or run it with tofupilot run.

Common Mistakes

MistakeConsequenceFix
Storing pass/fail onlyDrift is invisible until yield collapsesStore the measured values
Watching yield instead of measurementsYield moves lastWatch the distribution and Cpk
No station or fixture metadataDrift cannot be attributedRecord context on every run
Ignoring a falling Cpk with no failuresThe warning is wastedTreat a Cpk trend as actionable
Assuming the product driftedIt may be the benchRun a golden unit periodically
Forgetting a limit changeWeeks spent chasing a phantomCheck the procedure version first

More Guides

Put this guide into practice