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
| Pattern | Typical cause |
|---|---|
| Gradual shift in the mean | Stencil wear, instrument out of calibration, supplier change |
| Widening spread, stable mean | Fixture contact degrading, a second population appearing |
| Step change | Component lot change, firmware release, limit change |
| Sawtooth | Something reset periodically: recalibration, shift change |
| Drift on one station only | Fixture, cabling, or that station's instrument |
| Drift correlated with time of day | Ambient 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.yaml24 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.4def power_rails(measurements, dmm): measurements.rail_3v3 = dmm.dc_voltage()class Multimeter: def dc_voltage(self) -> float: return 3.31 # Replace with a real instrument readEvery 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:
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.py31 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
| Observation | Likely cause | Action |
|---|---|---|
| Slow mean shift, all stations | Supplier, process, or environmental | Check build records and component lots |
| Shift on one station | Fixture, cabling, instrument | Compare against another station; check calibration |
| Spread widening | Contact degradation | Measure contact resistance; service the fixture |
| Step change at a known date | Lot, firmware, or limit change | Check the procedure version first |
| Sawtooth | Something is being reset | Correlate with calibration or shift schedule |
| Bimodal | Two populations | Split 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.
- 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.34If 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
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
| Mistake | Consequence | Fix |
|---|---|---|
| Storing pass/fail only | Drift is invisible until yield collapses | Store the measured values |
| Watching yield instead of measurements | Yield moves last | Watch the distribution and Cpk |
| No station or fixture metadata | Drift cannot be attributed | Record context on every run |
| Ignoring a falling Cpk with no failures | The warning is wasted | Treat a Cpk trend as actionable |
| Assuming the product drifted | It may be the bench | Run a golden unit periodically |
| Forgetting a limit change | Weeks spent chasing a phantom | Check the procedure version first |
