PCDU Thermal Cycle and Burn-In

PCDU acceptance with TofuPilot Framework: FFT, four ECSS thermal cycles with RFT at every dwell, 200 h burn-in with a failure-free clock, RFT deltas.

TofuPilotEnvironmental TestPythonTofuPilot FrameworkGitHub
Open thermal chamber holding blue power distribution boards
Run this procedure.

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.

Close-up of a PCDU board in its open aluminium frame, one bus input connector and three rail output connectors

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

Chamber temperature in green cycling four times between -25 and +65 °C over 20 hours, the board temperature as an orange dotted line 7 °C above it, red dashed lines at the two acceptance levels, and blue dots at the end of each dwell where the reduced functional test was run.

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.

PCDU thermal test bench: a small thermal chamber with the unit visible through its window, a bus source instrument beside it

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.

  1. Setup: firmware, reset counter at zero, chamber at ambient, bus on.
  2. Main: full functional test at ambient, the reference stored on the plug.
  3. Main: four thermal cycles powered, chamber and board logged, rails read at every dwell.
  4. Main: 200 h burn-in at the hot plateau, input current, board temperature and reset counter logged.
  5. Main: reduced functional test at ambient, deltas to the reference.
  6. 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

procedure.yaml
phases
identify.py
functional.py
fft_ambient.py
thermal_cycling.py
burn_in.py
rft_ambient.py
safe_state.py
plugs
thermal_bench.py
utils
recipe.py
pyproject.toml
README.md

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 --json

The Procedure File

procedure.yaml declares the unit, the bench plug, and the three stages with every measurement and limit:

procedure.yaml · 281 lines
procedure.yaml
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_end

Framework features to notice:

  1. One function, two phases. phases/functional.py reads the rails across the load steps and computes the regulation per rail; fft_ambient and rft_ambient both call it, each with its own measurement target, so the before and after are the same code by construction.
  2. A count validated with ==. profile.chamber.cycles == 4 is the ECSS requirement as a number; a chamber that aborted after three cycles fails here, not on a temperature.
  3. A failure-free window from an event curve. The resets curve is the unit's own counter sampled over the burn-in; failure_free_h is the time from the last increment to the end, and the limit is the SMC-S-016 100 h.
  4. A finding with a ceiling. reset_events <= 1 lets one reset through as something to disposition while the failure-free clock decides the outcome; a second reset fails the unit.
  5. Deltas next to absolutes. rails_after has the same 1 % regulation limit as rails_before, and rail_delta_max_pct <= 0.5 sits beside it; the unit has to meet its limits and behave like it did.
  6. A reference through the plug. Phases run in separate processes and cannot read each other's measurements back, so fft_ambient stores its readings on the bench plug and rft_ambient recalls them.
  7. 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:

phases/identify.py
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:

phases/fft_ambient.py
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
phases/functional.py
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, eff

Thermal 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
phases/thermal_cycling.py
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
phases/burn_in.py
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} %")

Bus input current in green around 797 to 800 mA over 200 hours of burn-in, a red dashed vertical line at 41 hours where the watchdog reset was logged, and the 159 hours after it shaded green as the failure-free window.

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:

phases/rft_ambient.py
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} %")

Three panels, one per rail, showing the deviation from nominal in percent across the five load steps, the FFT before in gray and the RFT after in green almost on top of each other between 0 and -0.6 %, red dashed lines at plus and minus 1 %.

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.

Run your first test in minutes