Test Data & Analytics

Time-Series Test Data Analysis

Learn how to capture, store, and analyze time-series measurement data from hardware tests using TofuPilot's multi-dimensional arrays.

JJulien Buteau
intermediate10 min readMarch 14, 2026

Hardware tests produce waveforms, not just single numbers. A power supply ripple test captures thousands of voltage samples over time. A vibration test records acceleration spectra across frequency bands. Storing these natively lets you trend and compare waveforms across your production.

Single Values vs. Time-Series

Most test systems store one number per measurement: "output voltage = 3.31V." But the full story is in the waveform. That 3.31V might be a clean DC signal or a noisy mess that happens to average out to 3.31V.

Data typeExampleWhat it reveals
Single valueVout = 3.31VAverage output level
Time series1000 samples over 10msRipple, noise, transient behavior
Frequency spectrumFFT of output voltageSwitching noise frequency content
Multi-axisX/Y/Z accelerationVibration in all directions

Dimensioned measurements handle all of these.

Capturing Time-Series Data

With OpenHTF

Use dimensioned measurements to store time-series data.

ripple_test_openhtf.py
35 lines
import openhtf as htffrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("output_ripple_mv")        .with_dimensions("time_us")        .with_units("mV"),    htf.Measurement("ripple_pk_pk_mv")        .in_range(0, 50)        .with_units("mV"),)def ripple_test(test):    # Capture 1000 samples at 100kHz (10us spacing)    waveform = capture_oscilloscope(channel=1, samples=1000, rate_hz=100000)    for i, sample in enumerate(waveform):        test.measurements.output_ripple_mv[i * 10] = sample  # time in microseconds    # Also store the scalar summary    test.measurements.ripple_pk_pk_mv = max(waveform) - min(waveform)def main():    test = htf.Test(        ripple_test,        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",  # procedure UUID from the dashboard        part_number="PSU-100",    )    test.add_output_callbacks(upload())    test.execute(lambda: "PSU-2025-0099")if __name__ == "__main__":    main()

Install it with pip install "tofupilot[openhtf]". The upload() callback reads TOFUPILOT_API_KEY from the environment.

Note that the pass/fail limit is on the scalar (ripple_pk_pk_mv), not on the waveform. That split is deliberate and worth keeping: the scalar decides the verdict and drives trending, while the waveform is what you open when the scalar tells you something is wrong.

With the Python SDK

If you are not running OpenHTF, create the run directly:

ripple_test_client.py
36 lines
import osfrom datetime import datetime, timedelta, timezoneimport numpy as npfrom tofupilot.v2 import TofuPilotstarted = datetime.now(timezone.utc) - timedelta(seconds=5)ended = datetime.now(timezone.utc)waveform = capture_oscilloscope(channel=1, samples=1000, rate_hz=100000)pk_pk = float(np.max(waveform) - np.min(waveform))outcome = "PASS" if pk_pk < 50 else "FAIL"with TofuPilot(api_key=os.getenv("TOFUPILOT_API_KEY")) as client:    client.runs.create(        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",        serial_number="PSU-2025-0099",        part_number="PSU-100",        outcome=outcome,        started_at=started,        ended_at=ended,        phases=[{            "name": "Output Ripple",            "outcome": outcome,            "started_at": started,            "ended_at": ended,            "measurements": [{                "name": "Ripple Peak to Peak",                "measured_value": pk_pk,                "units": "mV",                "validators": [                    {"operator": "<=", "expected_value": 50},                ],            }],        }],    )

This uploads the extracted scalar rather than all 1000 samples. If you need the raw capture kept alongside the result, attach it as a file rather than expanding it into measurements.

Types of Time-Series Test Data

Voltage/Current Waveforms

Captured from oscilloscopes or DAQs during power-on sequences, ripple tests, or transient response tests. Store the raw waveform plus scalar summaries (peak-to-peak, RMS, rise time).

Temperature Profiles

Recorded during thermal cycling, burn-in, or heat dissipation tests. Multiple sensors produce multi-channel time-series data over minutes or hours.

thermal_profile.py
# Thermal test with 4 temperature sensors sampled every second for 30 minutessensors = ["junction", "ambient", "heatsink", "case"]duration_s = 1800samples_per_sensor = duration_s  # 1 sample/secondfor sensor_name in sensors:    readings = read_thermal_sensor(sensor_name, samples=samples_per_sensor)    measurements.append({        "name": f"temp_{sensor_name}",        "measured_value": max(readings),   # peak temperature is the gating value        "units": "degC",        "validators": [            {"operator": "<=", "expected_value": 85},        ],    })

Four sensors at 1800 samples each is 7200 points per unit. At production volume, store the features that gate the result (peak, time above threshold, final steady-state) and keep the full profile as an attachment.

Vibration Spectra

FFT data from accelerometers during vibration testing. Frequency on one axis, amplitude on the other.

Pressure/Flow Curves

Time-series pressure and flow measurements during leak tests, pneumatic tests, or hydraulic validation.

Analyzing Time-Series Across Production

The power of storing waveforms rather than only scalars is comparison across units.

Waveform Overlay

Compare ripple waveforms from 100 units. If 99 look the same and one has an extra spike, that unit has a problem the peak-to-peak measurement alone might not catch.

Statistical Bounds

Calculate the mean and standard deviation of your waveform at each time point across all units. This gives you an envelope of normal behavior. Any unit whose waveform falls outside the envelope is flagged.

waveform_statistics.py
import numpy as np# All waveforms from production (each is a list of 1000 samples)all_waveforms = np.array(waveforms_from_production)  # shape: (N_units, 1000)mean_waveform = np.mean(all_waveforms, axis=0)std_waveform = np.std(all_waveforms, axis=0)upper_bound = mean_waveform + 3 * std_waveformlower_bound = mean_waveform - 3 * std_waveform# Check if a new unit's waveform is within boundsnew_waveform = np.array(new_unit_data)is_anomalous = np.any(new_waveform > upper_bound) or np.any(new_waveform < lower_bound)

Two cautions before using this as a gate. With 1000 sample points and a 3-sigma band, roughly 3 points per unit will fall outside by chance alone, so np.any will flag a large share of good units. Require a run of consecutive out-of-band points, or compare against a tolerance band rather than a per-point sigma. And the envelope must be built from a period when the process was stable, otherwise you encode a drift as normal.

Trend Analysis on Waveform Features

Extract features from each waveform (rise time, settling time, overshoot, RMS) and trend them over production. A gradual increase in rise time across units suggests a component or process drift.

This is usually the highest-value analysis of the three, and the cheapest to store: a handful of numbers per unit that trend and alert like any other measurement.

Best Practices

PracticeWhy
Store both raw waveform and scalar summariesWaveforms for deep analysis, scalars for dashboards and trending
Use consistent sample ratesComparing waveforms requires the same number of points and timing
Include time axis metadataSample rate or time stamps so the waveform can be reconstructed
Set limits on scalar summariesScalars drive pass/fail, waveforms drive root cause analysis
Limit waveform sizeKeep arrays under 10,000 points per measurement for practical storage
Capture raw traces on failures or a sampleEvery unit at full rate outgrows its usefulness quickly

More Guides

Put this guide into practice