
Get the complete source, dependencies and setup instructions from the template repository.
Open the source on GitHub ↗Introduction
Electronic Unit Acceptance Environment Overview
Every electronic unit that goes onto a satellite is accepted the same way: it is tested at ambient, cycled through its acceptance temperatures while it operates, burned in powered for a fixed number of hours, and tested again at ambient to see whether the environment changed it. The power conditioning and distribution unit (PCDU) is the one unit everything else depends on, so it is the example here: a 28 V unregulated bus in, 3.3 V, 5 V and 12 V regulated rails out, 20 W of nominal load. The environment does two things. Thermal cycling finds the workmanship defects that show up as a solder joint or a connector that opens at temperature. Burn-in finds the infant mortalities that show up as a unit that resets itself after forty hours of running.

The unit under test in its open frame: one bus input connector, three rail outputs, and the converters between them. The acceptance test reads the rails at the outputs and the telemetry the unit reports about itself.
The sequence is written down in the standards, and the numbers differ between them. ECSS-E-ST-10-03C Rev.1 asks for four acceptance thermal cycles on an electronic unit, a full functional test at ambient before and after, and a reduced functional test at the hot and cold dwells (see ECSS-E-ST-10-03C Rev.1, Testing). SMC-S-016 asks for a 200 h powered burn-in with the last 100 h failure-free. NASA's GEVS asks for 1000 h at component level with 350 h at the hot plateau (see GSFC-STD-7000A). The "300 h" figure that circulates is not in any of them. The durations and the cycle count are therefore recipe constants in this template, and the rail regulation and efficiency limits are the unit's own data sheet, not a standard's.
Test Purpose
The procedure records one acceptance environment record per unit:
- Full functional test at ambient before: every rail across five load steps, load regulation per rail, efficiency at nominal load
- Four acceptance thermal cycles with the unit powered, chamber and board temperature logged, and the rails read at every hot and cold dwell
- 200 h powered burn-in at the hot plateau, with the input current drift, the board temperature, and a failure-free clock that restarts at every reset the unit logs
- Reduced functional test at ambient after, each rail and the efficiency compared to the reading taken before the environment

The mock unit through its four cycles: the chamber between -25 and +65 °C, the board 7 °C above it under 20 W of load, and a reduced functional test at the end of each of the eight dwells with the worst rail at 1.13 % from nominal. The dwells are where a marginal joint opens; the RFT at each one is what catches it.
The framework mechanics on show are one Python function shared by two phases that write to different measurements, three curves with the same aggregation and limit each, a count validated with ==, a failure-free window derived from an event counter curve, a finding with a ceiling that is not a failure, before-versus-after deltas next to absolute limits, a reference passed between phases through the plug, and progress components on two time-scaled long phases.
Equipment & Setup
To run the acceptance environment on a PCDU, the following are required:
- A thermal chamber that covers the acceptance range with margin and reports its temperature over an interface
- A bus source at 28 V with current readback, and one electronic load channel per rail
- A DMM on each rail's sense pair, or the unit's own rail telemetry if it is accurate enough
- The Device Under Test (DUT): a smallsat PCDU, 28 V in, 3.3 / 5 / 12 V out, 20 W nominal
- A TofuPilot Framework procedure to sequence the FFT, the cycles, the burn-in and the RFT
- The TofuPilot Dashboard to keep the before-and-after record per serial and trend the deltas across the lot
Hardware Components
Thermal Chamber
Any bench chamber that reaches the acceptance levels with a few degrees of margin and holds a dwell to within a degree does the job. Vötsch and ESPEC chambers expose set point and readback over Ethernet or Modbus. The rate of change is what the chamber can do, not a requirement; the recipe's 3 °C/min is a typical bench chamber. The unit is powered at nominal load throughout the cycles, which is why the board runs above the chamber and why the board temperature is its own curve with its own limit from the derating analysis.

The bench: the unit in the chamber, the bus source and the loads beside it, the harness through the chamber's port. The FFT and the RFT run at ambient with the chamber door closed and the same harness, so the before-and-after comparison is on the same wiring.
Bus Source and Loads
A Keysight N6705-class source on the bus gives the input current readback the efficiency and the burn-in drift come from. One electronic load channel per rail steps each rail through 0, 25, 50, 75 and 100 % of its share of the 20 W. The rails are read four-wire at the output connector; a unit that reports its own rail voltages over telemetry can be used instead when the telemetry has been calibrated against the DMM.
Unit Telemetry
The PCDU reports rail voltages, board temperature and a reset counter over its serial link. The reset counter is the burn-in's most important channel: a watchdog reset during the burn-in is the infant mortality the test exists to find, and the procedure does not need to catch it live because the unit logs it. The failure-free window is computed from the counter curve at the end.
Where the Limits Come From
The cycle count, four, and the structure of the sequence are ECSS-E-ST-10-03C. The acceptance levels are the operating range, -20 to +60 °C, with a 5 °C margin. The burn-in duration of 200 h with the last 100 h failure-free is SMC-S-016; a GEVS programme sets BURN_IN_H to 1000. The rail regulation limit of 1 % at ambient and 3 % at the dwells, the 88 % efficiency floor, the 75 °C board limit and the 2 % current drift are the unit's data sheet and its derating analysis. The 0.5 % before-versus-after delta on the rails is the limit a customer tightens after a few dozen units, because a unit that shifted is a unit with a story even when it is inside its limits.
Test Procedure
Overview
The procedure maps the acceptance environment onto the framework's three stages. Identification and the chamber check live in setup: so the bus is never switched on into an unknown chamber. Load off, bus off and chamber back at ambient live in teardown: so the unit is safe to unload whatever happened.
- Setup: firmware, reset counter at zero, chamber at ambient, bus on.
- Main: full functional test at ambient, the reference stored on the plug.
- Main: four thermal cycles powered, chamber and board logged, rails read at every dwell.
- Main: 200 h burn-in at the hot plateau, input current, board temperature and reset counter logged.
- Main: reduced functional test at ambient, deltas to the reference.
- Teardown: load off, bus off, chamber at ambient, reset counter read.
Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot for per-serial trending across the lot.
Why TofuPilot Framework?
TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:
- Automatic Python environment management (via
uv) - Operator UI (no frontend code needed)
- Measurement validation and live charts
- Process isolation between phases and equipment plugs
Project Structure
You can find the full source on GitHub. The ThermalBench plug is a mock of the chamber, the bus source, the loads and the unit's telemetry together, synthesizing a healthy unit whose rails sit within 0.6 % of nominal with a small temperature coefficient, 90 % efficiency at nominal load, and one watchdog reset at hour 41 of the burn-in, so the procedure runs end-to-end without a chamber or a unit connected.
tofupilot run .For CI or bench automation, the same run executes headless:
tofupilot run . --no-tui --no-kiosk --jsonThe Procedure File
procedure.yaml declares the unit, the bench plug, and the three stages with every measurement and limit:
procedure.yaml · 281 lines
name: PCDU Thermal Cycle and Burn-Inversion: 0.1.0description: Acceptance environment for a smallsat PCDU. Full functional test at ambient, four ECSS acceptance thermal cycles with a reduced functional test at every hot and cold dwell, 200 h powered burn-in with the last 100 h failure-free, then the reduced functional test at ambient compared rail by rail to the reference taken before.unit: auto_identify: true serial_number: description: "Scan the PCDU label" placeholder: "PCDU-28-0000" pattern: "^PCDU-28-\\d{4}$" default_value: "PCDU-28-0117" part_number: default_value: "PCDU-28-3R" batch_number: default_value: "LOT-2026-09"plugs: - name: Thermal Bench description: "Thermal chamber, 28 V bus source, electronic load per rail, unit telemetry over serial (mock, one plug per bench)" python: plugs.thermal_bench:ThermalBench key: benchsetup: - name: Identify key: identify python: phases.identify measurements: - name: Firmware Version key: firmware_version validators: - {operator: matches, expected_value: "^2\\.\\d+\\.\\d+$"} - name: Reset Count Start key: reset_count_start validators: - {operator: "==", expected_value: 0} - name: Chamber Start key: chamber_start_c unit: °C validators: - {operator: ">=", expected_value: 18.0} - {operator: "<=", expected_value: 28.0}main: - name: FFT Ambient key: fft_ambient python: phases.fft_ambient measurements: - name: Rails Before key: rails_before title: Rail voltage across the load steps, ambient, before the environment x_axis: legend: Load unit: "%" y_axis: - legend: 3.3 V key: rail_3v3 unit: V aggregations: - type: regulation_pct unit: "%" validators: - {operator: "<=", expected_value: 1.0} - legend: 5 V key: rail_5v0 unit: V aggregations: - type: regulation_pct unit: "%" validators: - {operator: "<=", expected_value: 1.0} - legend: 12 V key: rail_12v unit: V aggregations: - type: regulation_pct unit: "%" validators: - {operator: "<=", expected_value: 1.0} - name: Efficiency Before key: efficiency_before_pct unit: "%" validators: - {operator: ">=", expected_value: 88.0} - name: Thermal Cycling key: thermal_cycling python: phases.thermal_cycling depends_on: [fft_ambient] timeout: 24h ui: components: - key: cycling_progress type: progress label: "Thermal cycling" description: "4 cycles, -25 to +65 °C, 2 h dwells, powered" default_value: 0 max: 100 measurements: - name: Profile key: profile title: Chamber and board temperature through the four cycles x_axis: legend: Time unit: h y_axis: - legend: Chamber key: chamber unit: °C aggregations: - type: cycles validators: - {operator: "==", expected_value: 4} - type: min_c unit: °C validators: - {operator: "<=", expected_value: -24.0} - type: max_c unit: °C validators: - {operator: ">=", expected_value: 64.0} - {operator: "<=", expected_value: 68.0} - legend: Board key: board unit: °C aggregations: - type: max_c unit: °C validators: - {operator: "<=", expected_value: 75.0} - name: Rails At Dwells key: rails_at_dwells title: Reduced functional test at each hot and cold dwell x_axis: legend: Dwell y_axis: - legend: 3.3 V key: rail_3v3 unit: V aggregations: - type: max_deviation_pct unit: "%" validators: - {operator: "<=", expected_value: 3.0} - legend: 5 V key: rail_5v0 unit: V aggregations: - type: max_deviation_pct unit: "%" validators: - {operator: "<=", expected_value: 3.0} - legend: 12 V key: rail_12v unit: V aggregations: - type: max_deviation_pct unit: "%" validators: - {operator: "<=", expected_value: 3.0} - name: Burn In key: burn_in python: phases.burn_in depends_on: [thermal_cycling] timeout: 24h ui: components: - key: burn_in_progress type: progress label: "Burn-in" description: "200 h powered at 50 °C, last 100 h failure-free" default_value: 0 max: 100 measurements: - name: Burn-In key: burn_in title: Input current, board temperature and reset counter over the burn-in x_axis: legend: Time unit: h y_axis: - legend: Input current key: current unit: A aggregations: - type: hours unit: h validators: - {operator: ">=", expected_value: 200.0} - type: drift_pct unit: "%" validators: - {operator: ">=", expected_value: -2.0} - {operator: "<=", expected_value: 2.0} - legend: Board key: board unit: °C aggregations: - type: max_c unit: °C validators: - {operator: "<=", expected_value: 75.0} - legend: Resets key: resets aggregations: - type: failure_free_h unit: h validators: - {operator: ">=", expected_value: 100.0} - name: Reset Events key: reset_events description: Resets logged by the unit during the burn-in. One is a finding to be dispositioned, not a failure, as long as the last 100 h are clean; two restart the burn-in. validators: - {operator: "<=", expected_value: 1} - name: RFT Ambient key: rft_ambient python: phases.rft_ambient depends_on: [burn_in] measurements: - name: Rails After key: rails_after title: Rail voltage across the load steps, ambient, after the environment x_axis: legend: Load unit: "%" y_axis: - legend: 3.3 V key: rail_3v3 unit: V aggregations: - type: regulation_pct unit: "%" validators: - {operator: "<=", expected_value: 1.0} - legend: 5 V key: rail_5v0 unit: V aggregations: - type: regulation_pct unit: "%" validators: - {operator: "<=", expected_value: 1.0} - legend: 12 V key: rail_12v unit: V aggregations: - type: regulation_pct unit: "%" validators: - {operator: "<=", expected_value: 1.0} - name: Efficiency After key: efficiency_after_pct unit: "%" validators: - {operator: ">=", expected_value: 88.0} - name: Rail Delta Max key: rail_delta_max_pct unit: "%" description: Worst rail shift between the FFT before and the RFT after, any load step; a unit inside its limits but shifted has something to explain. validators: - {operator: "<=", expected_value: 0.5} - name: Efficiency Delta key: efficiency_delta_pct unit: "%" validators: - {operator: ">=", expected_value: -1.0} - {operator: "<=", expected_value: 1.0}teardown: - name: Safe State key: safe_state python: phases.safe_state measurements: - name: Chamber End key: chamber_end_c unit: °C validators: - {operator: "<=", expected_value: 30.0} - name: Reset Count End key: reset_count_endFramework features to notice:
- One function, two phases.
phases/functional.pyreads the rails across the load steps and computes the regulation per rail;fft_ambientandrft_ambientboth call it, each with its own measurement target, so the before and after are the same code by construction. - A count validated with
==.profile.chamber.cycles == 4is the ECSS requirement as a number; a chamber that aborted after three cycles fails here, not on a temperature. - A failure-free window from an event curve. The
resetscurve is the unit's own counter sampled over the burn-in;failure_free_his the time from the last increment to the end, and the limit is the SMC-S-016 100 h. - A finding with a ceiling.
reset_events <= 1lets one reset through as something to disposition while the failure-free clock decides the outcome; a second reset fails the unit. - Deltas next to absolutes.
rails_afterhas the same 1 % regulation limit asrails_before, andrail_delta_max_pct <= 0.5sits beside it; the unit has to meet its limits and behave like it did. - A reference through the plug. Phases run in separate processes and cannot read each other's measurements back, so
fft_ambientstores its readings on the bench plug andrft_ambientrecalls them. - Phase timeouts cap at 24 h. The 200 h burn-in is time-scaled in the mock; on real hardware it runs as daily segments of this phase or is read back from the chamber's logger, with the same aggregations either way.
Identify
The setup phase reads the firmware and the reset counter, which has to be zero before the burn-in means anything, checks the chamber is at ambient, records the chamber on the unit metadata and switches the bus on:
def identify(measurements, bench, unit, log): """Setup: unit telemetry, firmware, reset counter at zero, chamber at ambient before the bus is switched on.""" ident = bench.identify() measurements.firmware_version = ident["firmware"] measurements.reset_count_start = ident["reset_count"] chamber = bench.chamber_temp_c() measurements.chamber_start_c = chamber unit.metadata["chamber_id"] = "TC-02" bench.bus_on() log.info(f"PCDU {unit.serial_number}: fw {ident['firmware']}, {ident['reset_count']} resets logged, chamber at {chamber:.1f} C, bus on")FFT Ambient
The full functional test calls the shared function with the rails_before measurement as its target and stores the readings on the plug for the RFT to compare against:
from phases.functional import functional_testdef fft_ambient(measurements, bench, log): """Full functional test at ambient before the environment: every rail across the load steps, load regulation per rail, efficiency at nominal load. The reference the post-test RFT is compared against, kept on the bench plug because phases run in separate processes and cannot read each other's measurements back.""" readings, eff = functional_test(measurements, bench, log, measurements.rails_before, "efficiency_before_pct") bench.store("fft", {"rails": readings, "efficiency_pct": eff})The shared function steps the load, reads every rail at every step, writes each rail as a curve with a regulation_pct aggregation, and computes the efficiency at nominal load from the source's readback:
phases/functional.py · 30 lines
import numpy as npfrom utils.recipe import LOAD_STEPS_PCT, LOAD_W_NOMINAL, RAILS_Vdef functional_test(measurements, bench, log, rails_meas, efficiency_key): """Shared by the FFT and the RFT: rails across the load steps, load regulation aggregation per rail, efficiency at nominal load.""" readings = {k: [] for k in RAILS_V} eff = None for pct in LOAD_STEPS_PCT: bench.set_load_pct(pct) rails = bench.read_rails() for k in RAILS_V: readings[k].append(rails[k]) if pct == 100: inp = bench.read_input() eff = 100.0 * LOAD_W_NOMINAL / (inp["bus_v"] * inp["current_a"]) bench.set_load_pct(0) rails_meas.x_axis = LOAD_STEPS_PCT reg = {} for k, nom in RAILS_V.items(): v = np.array(readings[k]) setattr(rails_meas.y_axis, k, v.tolist()) reg[k] = float(100.0 * np.abs(v - nom).max() / nom) getattr(rails_meas.y_axis, k).aggregations.regulation_pct = reg[k] setattr(measurements, efficiency_key, float(eff)) log.info("Rails " + ", ".join(f"{k} {reg[k]:.2f} %" for k in RAILS_V) + f", efficiency {eff:.1f} % at {LOAD_W_NOMINAL:.0f} W") return readings, effThermal Cycling
One call returns the four cycles as a chamber and a board curve plus the rails read at each dwell. The phase counts the cycles as the smaller of the hot and cold dwells reached, records the profile with its aggregations, and writes the reduced functional test as one curve per rail across the eight dwells:
phases/thermal_cycling.py · 38 lines
import numpy as npfrom utils.recipe import CYCLES, RAILS_V, T_ACC_MAX_C, T_ACC_MIN_Cdef thermal_cycling(measurements, bench, ui, log): """ECSS-E-ST-10-03C acceptance thermal cycling: four cycles between the acceptance temperatures with the unit powered at nominal load, chamber and board temperature logged, and a reduced functional test (all rails) at every hot and cold dwell.""" bench.set_load_pct(100) cap = bench.thermal_cycles() ui.cycling_progress = 100 chamber = np.array(cap["chamber_c"]) board = np.array(cap["board_c"]) # A cycle is one hot dwell and one cold dwell reached. hot = int(sum(1 for d in cap["dwell_temp_c"] if d >= T_ACC_MAX_C - 1.0)) cold = int(sum(1 for d in cap["dwell_temp_c"] if d <= T_ACC_MIN_C + 1.0)) cycles = min(hot, cold) measurements.profile.x_axis = [round(m / 60.0, 2) for m in cap["time_min"]] measurements.profile.y_axis.chamber = cap["chamber_c"] measurements.profile.y_axis.chamber.aggregations.cycles = cycles measurements.profile.y_axis.chamber.aggregations.min_c = float(chamber.min()) measurements.profile.y_axis.chamber.aggregations.max_c = float(chamber.max()) measurements.profile.y_axis.board = cap["board_c"] measurements.profile.y_axis.board.aggregations.max_c = float(board.max()) # Reduced functional test at each dwell: the worst rail deviation. measurements.rails_at_dwells.x_axis = list(range(1, len(cap["dwell_rails"]) + 1)) worst = {} for k, nom in RAILS_V.items(): v = np.array([d[k] for d in cap["dwell_rails"]]) setattr(measurements.rails_at_dwells.y_axis, k, v.tolist()) worst[k] = float(100.0 * np.abs(v - nom).max() / nom) getattr(measurements.rails_at_dwells.y_axis, k).aggregations.max_deviation_pct = worst[k] bench.set_load_pct(0) log.info(f"{cycles}/{CYCLES} cycles, chamber {chamber.min():.1f}..{chamber.max():.1f} C, board to {board.max():.1f} C, worst rail at a dwell " + ", ".join(f"{k} {worst[k]:.2f} %" for k in RAILS_V))Burn In
The chamber goes to the hot plateau, the unit runs at nominal load for 200 h, and the phase reads back the input current, the board temperature and the reset counter. The failure-free time is the time from the last reset to the end; the current drift is the last five hours against the first five:
phases/burn_in.py · 34 lines
import numpy as npfrom utils.recipe import BURN_IN_H, BURN_IN_T_Cdef burn_in(measurements, bench, ui, log): """SMC-S-016 acceptance burn-in: powered at nominal load at the hot plateau for 200 h, the last 100 h failure-free. Any reset logged by the unit restarts the failure-free clock; the aggregation is the clean time at the end, not the total.""" bench.chamber_set(BURN_IN_T_C) bench.set_load_pct(100) cap = bench.burn_in(BURN_IN_H) ui.burn_in_progress = 100 t = np.array(cap["time_h"]) cur = np.array(cap["current_a"]) resets = np.array(cap["reset_count"]) events = np.flatnonzero(np.diff(resets) > 0) last_event_h = float(t[events[-1] + 1]) if events.size else 0.0 failure_free_h = float(t[-1] - last_event_h) drift_pct = float(100.0 * (cur[-50:].mean() - cur[:50].mean()) / cur[:50].mean()) measurements.burn_in.x_axis = cap["time_h"] measurements.burn_in.y_axis.current = cap["current_a"] measurements.burn_in.y_axis.current.aggregations.hours = float(t[-1]) measurements.burn_in.y_axis.current.aggregations.drift_pct = drift_pct measurements.burn_in.y_axis.board = cap["board_c"] measurements.burn_in.y_axis.board.aggregations.max_c = float(max(cap["board_c"])) measurements.burn_in.y_axis.resets = cap["reset_count"] measurements.burn_in.y_axis.resets.aggregations.failure_free_h = failure_free_h measurements.reset_events = int(events.size) bench.set_load_pct(0) bench.chamber_set(23.0) log.info(f"Burn-in {t[-1]:.0f} h at {BURN_IN_T_C:.0f} C: {events.size} reset(s), last at {last_event_h:.1f} h, {failure_free_h:.0f} h failure-free, input current drift {drift_pct:+.2f} %")
The mock unit's burn-in: one watchdog reset at hour 41, 159 h failure-free after it against the 100 h limit, and an input current that drifts 0.53 % over the 200 h as the converters settle. A reset at hour 150 would leave 50 h clean and fail the unit; a second reset anywhere fails it on the count.
RFT Ambient
The reduced functional test calls the same function with rails_after as its target, recalls the FFT readings from the plug, and computes the worst rail shift and the efficiency delta:
import numpy as npfrom phases.functional import functional_testfrom utils.recipe import RAILS_Vdef rft_ambient(measurements, bench, log): """Reduced functional test at ambient after the environment: the same rails and efficiency, each compared to the FFT reading before the environment. The delta is the number that catches a unit that still meets its limits but no longer behaves like it did.""" after, eff_after = functional_test(measurements, bench, log, measurements.rails_after, "efficiency_after_pct") ref = bench.recall("fft") deltas = {k: float(100.0 * np.abs(np.array(after[k]) - np.array(ref["rails"][k])).max() / RAILS_V[k]) for k in RAILS_V} measurements.rail_delta_max_pct = max(deltas.values()) measurements.efficiency_delta_pct = float(eff_after - ref["efficiency_pct"]) log.info("Before vs after: " + ", ".join(f"{k} {deltas[k]:.3f} %" for k in RAILS_V) + f", efficiency {ref['efficiency_pct']:.1f} -> {eff_after:.1f} %")
Before and after on the mock unit: the three rails droop 0.4 to 0.6 % from no load to full load both times, the worst shift between the two is 0.049 % on the 12 V rail against the 0.5 % limit, and the efficiency moved from 89.7 to 89.9 %. A rail that came back 0.3 % different is inside every absolute limit and is still the trace of a component that changed in the chamber.
Mock Plug
ThermalBench stands in for the chamber, the bus source, the loads and the unit's telemetry. It draws three rails with a fixed offset of a few tenths of a percent, a temperature coefficient of about -0.012 %/°C and 0.4 % of load regulation, 91 % converter efficiency plus 0.35 W of housekeeping, a board 7 °C above the chamber at full load, four cycles ramped at 3 °C/min with 2 h dwells and the rails read at each one, and a burn-in whose input current settles 0.6 % over the first days with one watchdog reset at hour 41. The cycles and the burn-in are time-scaled and return in one call each. Every method returns plain Python types because plug calls cross a JSON boundary; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines, and the FFT reference crosses phases through the plug's store and recall.
On a real station, the class speaks the chamber's protocol for set point and readback, SCPI to the bus source and the loads over pyvisa, and the unit's protocol over pyserial for the rail telemetry and the reset counter. Set TIME_SCALE = 1.0 in utils/recipe.py for the real cycles and burn-in, split the burn-in into daily segments or read it back from the chamber's logger because a phase timeout caps at 24 h, and set BURN_IN_H and FAILURE_FREE_H to the programme's standard. The phases, measurements and limits stay the same.
