Functional circuit test (FCT) is the last electrical gate before a board ships or goes into a higher assembly. In-circuit test proves the components are present and correct; functional test proves the assembled board does its job. This guide covers what to measure, how to structure the test, and how to track the results so the line tells you when something changes.
What FCT Covers
| Area | Typical checks |
|---|---|
| Power | Rail voltages under load, inrush, quiescent and active current |
| Programming | Flash firmware, verify the image, read back the version |
| Digital I/O | GPIO loopback, interrupt response, bus enumeration |
| Analog | ADC accuracy, DAC output, reference voltage, sensor readback |
| Communication | UART, SPI, I2C, CAN, Ethernet link and data integrity |
| RF | Transmit power, receive sensitivity, frequency error |
| Calibration | Measure offsets, compute coefficients, write them to the DUT |
| Safety | Hipot, leakage, ground bond where applicable |
FCT is usually 100 % inspection: every board, every time. That makes it the largest source of test data you have, and the place where first pass yield is normally measured.
FCT vs the Other PCBA Tests
| Test | Catches | Misses |
|---|---|---|
| AOI/SPI/AXI | Solder and placement defects, visually | Anything electrical |
| ICT / flying probe | Wrong, missing, reversed components; shorts and opens | Whether the system works |
| FCT | System-level failures, firmware, calibration | Component-level root cause |
| Burn-in | Infant mortality | Anything a short test would find |
A board that passes ICT and fails FCT usually has a design-marginal or firmware problem, not an assembly defect. That distinction is only visible if both stages record measurements rather than verdicts.
Structuring the Test
The fixture, the instruments and the DUT connection each become a plug. The test steps become phases. The limits live in the procedure as data.
The plugs
class Fixture: """Pogo-pin fixture: lid detection and DUT power.""" def lid_closed(self) -> bool: return True # Replace with a real interlock read def power_on(self): pass # Replace with a real relay or PSU call def power_off(self): pass def close(self): self.power_off()class Multimeter: def measure_dc_voltage(self) -> float: return 3.31 # Replace with a PyVISA query def measure_dc_current_ma(self) -> float: return 41.3class Dut: """Serial console or debug probe on the board under test.""" def query_firmware(self) -> str: return "2.1.0" def read_adc_counts(self) -> int: return 2048 def gpio_loopback(self) -> bool: return TrueThe phases
def safety_check(measurements, fixture): """Refuse to energise the board if the lid is open.""" measurements.lid_closed = fixture.lid_closed()def power_rails(measurements, fixture, dmm): fixture.power_on() measurements.rail_3v3 = dmm.measure_dc_voltage() measurements.quiescent_current = dmm.measure_dc_current_ma()def firmware_check(measurements, dut): measurements.firmware_version = dut.query_firmware()def analog_check(measurements, dut): measurements.adc_counts = dut.read_adc_counts()def digital_check(measurements, dut): measurements.gpio_loopback = dut.gpio_loopback()The procedure
procedure.yaml73 lines
name: PCBA Functional Testversion: 1.0.0description: FCT for PCBA-100, per TP-100 rev Bunit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"plugs: - name: fixture python: plugs.fixture:Fixture - name: dmm python: plugs.dmm:Multimeter - name: dut python: plugs.dut:Dutmain: - name: Safety Check python: phases.safety_check measurements: - name: Lid Closed description: Fixture interlock before power is applied validators: - operator: "==" expected_value: true - name: Power Rails python: phases.power_rails depends_on: [safety_check] measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4 - name: Quiescent Current unit: mA validators: - operator: "<=" expected_value: 45.0 - name: Firmware Check python: phases.firmware_check depends_on: [power_rails] measurements: - name: Firmware Version validators: - operator: "==" expected_value: "2.1.0" - name: Analog Check python: phases.analog_check depends_on: [power_rails] measurements: - name: ADC Counts validators: - operator: ">=" expected_value: 2000 - operator: "<=" expected_value: 2096 - name: Digital Check python: phases.digital_check depends_on: [power_rails] measurements: - name: GPIO Loopback validators: - operator: "==" expected_value: trueThe dependency graph carries real meaning here. Safety runs first and everything waits on it. Power runs next. Firmware, analog and digital all depend on power but not on each other, so the engine runs those three concurrently and the cycle time is the longest of them rather than their sum.
Run it with tofupilot run ./procedure.yaml.
Three Decisions That Determine Whether the Data Is Useful
Record the value, not the verdict. rail_3v3 = 3.31 with limits attached supports Cpk, drift detection and a limit review later. A boolean power_ok supports none of those, and cannot tell you the rail has been creeping toward the limit for three weeks.
Keep measurement names stable. The name is the identity of the measurement across every run ever taken. Renaming Rail 3V3 to 3V3 Rail splits the history in two. Pick names at the start and leave them alone.
Continue past failures where it is safe. A board that fails the 3.3 V rail will usually fail several downstream checks too, and knowing which ones is diagnostic. A board that fails the lid interlock must stop immediately. That is exactly the distinction depends_on encodes.
Testing Several Boards at Once
When the fixture holds more than one board, declare slots. Each slot gets its own unit, its own measurements and its own outcome, and they run concurrently:
execution: slots: - name: Slot 1 - name: Slot 2 - name: Slot 3 - name: Slot 4A failure in one slot stops that slot only; the rest keep testing. See how to run parallel tests on multiple DUTs.
Calibration Inside FCT
If the board needs calibration coefficients written into it, that is an FCT phase, and both the raw measurement and the computed coefficient belong in the record:
def calibrate(measurements, dut, dmm): reference = dmm.measure_dc_voltage() counts = dut.read_adc_counts() gain = reference / counts if counts else 0.0 measurements.reference_voltage = reference measurements.adc_counts = counts measurements.adc_gain = gain dut.write_calibration(gain)Storing the coefficient per serial means a field failure can be checked against what was actually programmed into that unit.
Reading the Results
| Question | Where it is answered |
|---|---|
| What is our yield this shift? | First pass yield on the procedure |
| Which step costs us the most units? | Pareto of failing measurements |
| Is a measurement drifting toward its limit? | Control chart on that measurement |
| Are our limits too tight? | Cpk per measurement |
| Is one fixture worse than the others? | Filter by slot or station |
| What did this returned board measure? | The unit history for that serial |
If You Already Run OpenHTF
Existing OpenHTF suites run natively on the TofuPilot CLI with no rewrite:
fct.py24 lines
import openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass DutPlug(BasePlug): def read_voltage(self) -> float: return 3.31@htf.measures( htf.Measurement("rail_3v3") .in_range(3.2, 3.4) .with_units(units.VOLT),)@htf.plug(dut=DutPlug)def test_power_rails(test, dut): test.measurements.rail_3v3 = dut.read_voltage()test = htf.Test(test_power_rails, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Recording pass/fail only | No Cpk, no drift detection, no limit review | Record the measured value with limits |
| Renaming measurements | History splits, trends break | Fix names early and keep them |
| One giant phase | No retry granularity, no parallelism, poor diagnostics | Split into phases along dependency lines |
| Powering the board before the interlock check | Safety incident, damaged fixture | Gate power on a safety phase with depends_on |
| Limits copied from the datasheet forever | Either scrapping good boards or shipping marginal ones | Review limits against the measured distribution |
| Not recording the fixture or station | Cannot tell a bad fixture from a bad lot | Record station and slot on every run |
