A functional test specification defines every measurement your board must pass before it ships. It maps schematic nets to physical test points, sets pass/fail limits from datasheets, and becomes the contract between hardware design and production test. This guide shows you how to build that document and turn it into executable OpenHTF phases tracked in TofuPilot.
What a Functional Test Spec Is
A functional test spec is a structured table that answers three questions for every signal on your board:
- What are you measuring? (net name, test point, measurement type)
- What is the expected value? (nominal from schematic or datasheet)
- What is the acceptable range? (min/max limits with tolerance)
It is not a test script. It is the source of truth that drives the test script.
Mapping Schematic Nets to Test Points
Start with your schematic and export a net list. For each net that carries a testable signal, identify the physical access point on the board.
| Net Name | Test Point | Signal Type | Nominal | Notes |
|---|---|---|---|---|
| VCC_3V3 | TP1 | DC voltage | 3.3 V | LDO output |
| VCC_5V0 | TP2 | DC voltage | 5.0 V | USB VBUS |
| XTAL_OUT | TP3 | Frequency | 12 MHz | Crystal oscillator |
| I2C_SDA | TP4 | Logic high | 3.3 V | Pull-up to VCC_3V3 |
| VBAT | TP5 | DC voltage | 3.7 V | Li-ion nominal |
| LED_PWM | TP6 | Duty cycle | 50% | Default firmware state |
Rules for choosing test points:
- Prefer dedicated test vias over component pads
- Include a ground reference TP near each measurement cluster
- Number test points in physical order to reduce fixture wiring complexity
Defining Limits from Datasheets
Limits come from two sources: component datasheets and system-level margins. Trace every limit to a document and a line item.
Voltage Regulators
For an LDO like the TLV1117-3.3, the datasheet specifies:
- Output voltage accuracy: +/-1% over temperature
- Line regulation: 0.2% typical
- Load regulation: 0.4% typical
At 3.3 V nominal with +/-1% accuracy plus 0.4% load regulation:
| Net | Nominal | Min | Max | Source |
|---|---|---|---|---|
| VCC_3V3 | 3.300 V | 3.234 V | 3.366 V | TLV1117-3.3 DS, Table 6.6 |
Crystal Oscillators
A 12 MHz crystal with +/-20 ppm tolerance at 25 C:
- 20 ppm of 12 MHz = 240 Hz
| Net | Nominal | Min | Max | Source |
|---|---|---|---|---|
| XTAL_OUT | 12 000 000 Hz | 11 999 760 Hz | 12 000 240 Hz | ABM8 DS, ppm spec |
Logic Signals
Use the component's VIH and VIL thresholds, not 50% of VCC.
| Net | Type | Min | Max | Source |
|---|---|---|---|---|
| I2C_SDA (high) | V_OH | 2.97 V | 3.63 V | GPIO spec, 0.9VCC to 1.1VCC |
| I2C_SCL (low) | V_OL | 0 V | 0.33 V | GPIO spec, V_OL max 0.1*VCC |
Test Specification Document Structure
Store the spec as a versioned table. One row per measurement.
| ID | Test Point | Net | Phase Name | Measurement Type | Nominal | Min | Max | Unit | Datasheet Ref |
|---|---|---|---|---|---|---|---|---|---|
| M01 | TP1 | VCC_3V3 | power_rails | DC voltage | 3.300 | 3.234 | 3.366 | V | TLV1117-3.3 DS Table 6.6 |
| M02 | TP2 | VCC_5V0 | power_rails | DC voltage | 5.000 | 4.750 | 5.250 | V | USB 2.0 spec section 7.2.1 |
| M03 | TP3 | XTAL_OUT | clock_check | Frequency | 12000000 | 11999760 | 12000240 | Hz | ABM8 DS ppm spec |
| M04 | TP4 | I2C_SDA | i2c_bus | DC voltage | 3.300 | 2.970 | 3.630 | V | MCU GPIO spec |
| M05 | TP5 | VBAT | battery_voltage | DC voltage | 3.700 | 3.000 | 4.200 | V | Li-ion cell spec |
| M06 | TP6 | LED_PWM | led_driver | Duty cycle | 50.0 | 45.0 | 55.0 | % | Firmware requirement |
Keep this table in a CSV or spreadsheet under version control alongside the firmware. Tag each spec revision to the hardware revision it applies to.
Translating the Spec into OpenHTF Phases
Each phase corresponds to a functional block in the spec. Measurements within a phase map to individual rows by ID.
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot
from plugs.dmm import DmmPlug
from plugs.counter import FrequencyCounterPlug
@htf.plug(dmm=DmmPlug)
@htf.measures(
htf.Measurement("vcc_3v3_voltage")
.in_range(minimum=3.234, maximum=3.366)
.with_units(units.VOLT)
.doc("M01: VCC_3V3 at TP1, TLV1117-3.3 DS Table 6.6"),
htf.Measurement("vcc_5v0_voltage")
.in_range(minimum=4.750, maximum=5.250)
.with_units(units.VOLT)
.doc("M02: VCC_5V0 at TP2, USB 2.0 spec 7.2.1"),
)
def power_rails(test, dmm):
test.measurements.vcc_3v3_voltage = dmm.measure_dc_voltage(channel=1)
test.measurements.vcc_5v0_voltage = dmm.measure_dc_voltage(channel=2)
@htf.plug(counter=FrequencyCounterPlug)
@htf.measures(
htf.Measurement("xtal_frequency")
.in_range(minimum=11_999_760, maximum=12_000_240)
.with_units(units.HERTZ)
.doc("M03: XTAL_OUT at TP3, ABM8 DS ppm spec"),
)
def clock_check(test, counter):
test.measurements.xtal_frequency = counter.measure_frequency(channel=1)
@htf.plug(dmm=DmmPlug)
@htf.measures(
htf.Measurement("i2c_sda_high_voltage")
.in_range(minimum=2.970, maximum=3.630)
.with_units(units.VOLT)
.doc("M04: I2C_SDA at TP4, MCU GPIO spec"),
)
def i2c_bus(test, dmm):
test.measurements.i2c_sda_high_voltage = dmm.measure_dc_voltage(channel=3)
@htf.plug(dmm=DmmPlug)
@htf.measures(
htf.Measurement("vbat_voltage")
.in_range(minimum=3.000, maximum=4.200)
.with_units(units.VOLT)
.doc("M05: VBAT at TP5, Li-ion cell spec"),
)
def battery_voltage(test, dmm):
test.measurements.vbat_voltage = dmm.measure_dc_voltage(channel=4)
@htf.plug(counter=FrequencyCounterPlug)
@htf.measures(
htf.Measurement("led_pwm_duty_cycle")
.in_range(minimum=45.0, maximum=55.0)
.doc("M06: LED_PWM at TP6, firmware requirement"),
)
def led_driver(test, counter):
test.measurements.led_pwm_duty_cycle = counter.measure_duty_cycle(channel=2)
def main():
test = htf.Test(
power_rails,
clock_check,
i2c_bus,
battery_voltage,
led_driver,
test_name="PCBA Functional Test",
)
with TofuPilot(test):
test.execute(test_start=lambda: input("Enter DUT serial number: "))
if __name__ == "__main__":
main()The .doc() string on each measurement links it back to the spec ID, making the test output self-auditing. When a measurement fails in TofuPilot, you can trace it directly to the datasheet row that defined the limit.
Tracking Spec Coverage in TofuPilot
After your first run, cross-reference the spec table against the TofuPilot measurement list:
| Spec ID | Measurement Name | TofuPilot Status |
|---|---|---|
| M01 | vcc_3v3_voltage | present |
| M02 | vcc_5v0_voltage | present |
| M03 | xtal_frequency | present |
| M04 | i2c_sda_high_voltage | present |
| M05 | vbat_voltage | present |
| M06 | led_pwm_duty_cycle | present |
A measurement that never fails across hundreds of units may indicate limits that are too loose. Review the distribution in TofuPilot's measurement analytics and tighten limits if the histogram shows all values clustered far from the edges.
Updating Limits
When hardware revision B changes the LDO, update the spec table and code together:
from openhtf.util import units
# AP2112K-3.3: Vout accuracy +/-1.5%, load reg 0.3%
# Min: 3.300 * (1 - 0.015 - 0.003) = 3.2406
# Max: 3.300 * (1 + 0.015 + 0.003) = 3.3594
htf.Measurement("vcc_3v3_voltage")
.in_range(minimum=3.2406, maximum=3.3594)
.with_units(units.VOLT)
.doc("M01: VCC_3V3 at TP1, AP2112K-3.3 DS rev B"),Commit the spec table update and the code change in the same commit so the audit trail stays intact.