TVAC Bakeout and Thermal Cycling

Star tracker TVAC with TofuPilot Framework: pump-down, bakeout judged on the TQCM outgassing rate, four thermal vacuum cycles with a centroid check per dwell.

TofuPilotEnvironmental TestPythonTofuPilot FrameworkGitHub
Open thermal vacuum chamber beside its control cabinet
Run this procedure.

Get the complete source, dependencies and setup instructions from the template repository.

Open the source on GitHub ↗

Introduction

Thermal Vacuum Overview

A star tracker is a camera whose lens must stay clean and whose focus must hold from -25 to +50 °C in vacuum. Two things on the ground threaten that. Everything inside the unit and around it outgasses when the air is gone, and what comes off the harness, the potting and the adhesives condenses on the coldest optical surface in reach. And every solder joint, every bond and every threaded insert sees its full thermal range for the first time under vacuum, where there is no convection to soften the gradients. The thermal vacuum run answers both: a bakeout with a witness that measures what the unit gives off, and acceptance cycles with a functional check at each extreme.

Close-up of a star tracker unit with its lens baffle on the chamber plate, the TQCM crystal head on a post facing the lens

The unit on the chamber plate with the TQCM's crystal head on its post, facing the lens. The crystal is held cold so that whatever condenses on the optics also condenses on it, and its resonant frequency drops in proportion to the mass it collects.

The cycling is written down. ECSS-E-ST-10-03C Rev.1 asks for a pressure at or below 1e-5 hPa, four acceptance thermal vacuum cycles on a unit (eight for qualification), and a functional test at the hot and cold dwells (see ECSS-E-ST-10-03C Rev.1, Testing). The bakeout is not: no standard gives its duration, because it depends on what the unit is made of. It ends when the outgassing rate the witness sees falls under the programme's figure and stays there. A thermoelectric quartz crystal microbalance (TQCM) at its collection temperature reads that rate as a frequency slope in hertz per hour; on this programme the crystal at -20 °C must read under 1 Hz/h for the last six hours of a bake of at least 36 h at +60 °C, and the total frequency shift over the bake, the mass collected, has its own ceiling.

Test Purpose

The procedure records one thermal vacuum record per unit:

  • Pump-down to the ECSS pressure, with the time it takes as a health figure for the chamber and for the unit's outgassing load
  • Bakeout at +60 °C with the TQCM at -20 °C facing the unit: the frequency curve, the rate derived from it, the clean window at the end and the total mass collected
  • Four acceptance thermal vacuum cycles with the tracker powered, its temperature logged, and at every hot and cold dwell the centroid error on a simulated star projected through the chamber window and the bus power
  • Backfill with dry nitrogen, gated on the unit being above the room's dew point so the lens does not fog

Two panels: on the left the chamber pressure falling on a log scale from 1000 hPa to 5e-6 hPa over five hours with a red dashed line at 1e-5 hPa; on the right the TQCM frequency shift rising in green to 320 Hz over a 44 hour bake with the outgassing rate as an orange dotted line falling below the 1 Hz/h red dashed line and the last nine hours shaded as the clean window.

The mock unit's pump-down and bake: under 1e-5 hPa in 4.6 h, then 44 h at +60 °C during which the crystal collected 320 Hz while the rate decayed from 40 Hz/h to 0.2 Hz/h, clean for the last 9 h against the 6 h window. A unit with uncured potting or a contaminated harness keeps the rate above 1 Hz/h for days and hits the 72 h ceiling, which is a finding for the materials list, not a chamber problem.

The framework mechanics on show are a derived curve stored beside its raw one with the limits on the derivative, a duration window as two validators on one aggregation, a count validated with == beside a level validated as a window, a curve kept for the record with no aggregation next to two that are judged, very small numbers as limits, progress components on three time-scaled phases, and a teardown gate that protects the hardware.

Equipment & Setup

To run thermal vacuum on optical units, the following are required:

  • A thermal vacuum chamber with a shroud, a temperature-controlled plate, a turbo pump and an ion gauge, and a window the star simulator can see through
  • A TQCM on a cold finger, positioned to see the same surfaces the optics see
  • A star simulator outside the window, a collimated point source the tracker can centroid
  • The Device Under Test (DUT): a smallsat star tracker on its bracket, with its power and data harness through a feedthrough
  • A TofuPilot Framework procedure to sequence the pump-down, the bake, the cycles and the backfill
  • The TofuPilot Dashboard to keep the bake record per serial and trend the centroid error at the dwells across the lot

Hardware Components

Chamber, Pump and Gauge

A bench chamber with a shroud and a plate covers a unit this size. The chamber controller exposes the shroud and plate set points and the valves over Modbus; the ion gauge reports over RS-232. The pressure requirement is ECSS's 1e-5 hPa; the time to reach it is not a requirement but a diagnostic the procedure records, because a chamber that used to pump down in four hours and now takes seven has a leak or a wet load.

Thermal vacuum bench: a cylindrical chamber with its door open showing the star tracker on the plate inside, an instrument rack beside it

The bench with the door open for loading: the tracker on the plate inside, the rack with the chamber controller, the gauge and the QCM controller beside it, the harness through a flange.

TQCM

The witness is a quartz crystal held at a set temperature by a thermoelectric element, with its resonant frequency read by a controller of the CrystalTek or QCM Research class. Mass condensing on the crystal lowers the frequency; the procedure records the shift as a positive number. Two things matter: the crystal's temperature, which sets what condenses on it (on this programme -20 °C, cold enough to collect what would reach the lens), and its view, which must include the unit and not the chamber walls. The rate criterion and the total-shift ceiling are the programme's contamination control plan, and they are recipe constants here.

The functional check at each dwell is the tracker's own job: centroid a point source. A collimated source outside the window gives a star at a known angle, and the tracker reports the centroid over its Ethernet link along with its bus power and its temperature. The centroid error grows at the cold end as the lens contracts and the focus moves; the limit is the unit's own accuracy specification with margin.

Where the Limits Come From

The pressure and the four cycles are ECSS-E-ST-10-03C. The acceptance levels are the operating range, -25 to +50 °C, with a 5 °C margin. The bake temperature, the 36 h minimum and 72 h maximum, the 1 Hz/h rate at -20 °C, the 6 h clean window and the 800 Hz total shift are the programme's contamination control plan for an optical unit. The 10 arcsec centroid ceiling and the 3.5 W power ceiling are the tracker's data sheet. The 15 °C backfill gate is the room's dew point with margin.

Test Procedure

Overview

The procedure maps the run onto the framework's three stages. Identification lives in setup: with the chamber still at ambient. The three long phases are chained with depends_on because they share one chamber. The backfill lives in teardown: so the chamber comes back to air safely whether the run passed or not, and only once the unit is warm enough.

  1. Setup: firmware, unit temperature, chamber at ambient.
  2. Main: pump-down, pressure curve, time to vacuum.
  3. Main: bakeout, TQCM curve, rate, clean window, total shift.
  4. Main: four thermal vacuum cycles, profile, centroid and power at each dwell.
  5. Teardown: backfill with dry nitrogen once the unit is above the dew point.

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
pump_down.py
bakeout.py
tvac_cycling.py
backfill.py
plugs
tvac_bench.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The TvacBench plug is a mock of the chamber, the gauge, the TQCM and the tracker's link together, synthesizing a healthy unit that pumps down to 5e-6 hPa in five hours, outgasses at a rate that decays from 40 Hz/h with an 8 h time constant, and centroids within 6 arcsec at the cold dwells, 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 · 211 lines
procedure.yaml
name: TVAC Bakeout and Thermal Cyclingversion: 0.1.0description: Thermal vacuum run for a smallsat star tracker. Pump-down to the ECSS pressure, bakeout at +60 C with a TQCM witness judged on the outgassing rate and the mass collected, four acceptance thermal vacuum cycles with a centroid check on a simulated star at every dwell, and a dry-nitrogen backfill gated on the unit being above the dew point.unit:  auto_identify: true  serial_number:    description: "Scan the tracker's label"    placeholder: "ST-200-0000"    pattern: "^ST-200-\\d{4}$"    default_value: "ST-200-0088"  part_number:    default_value: "ST-200-FM"  batch_number:    default_value: "LOT-2026-09"plugs:  - name: TVAC Bench    description: "Chamber shroud and plate, turbo pump and ion gauge, TQCM on a cold finger, star tracker telemetry and image link (mock, one plug per chamber)"    python: plugs.tvac_bench:TvacBench    key: benchsetup:  - name: Identify    key: identify    python: phases.identify    measurements:      - name: Firmware Version        key: firmware_version        validators:          - {operator: matches, expected_value: "^3\\.\\d+\\.\\d+$"}      - name: Unit Temperature Start        key: unit_temp_start_c        unit: °C        validators:          - {operator: ">=", expected_value: 18.0}          - {operator: "<=", expected_value: 26.0}      - name: Pressure Start        key: pressure_start_hpa        unit: hPa        validators:          - {operator: ">=", expected_value: 900.0}main:  - name: Pump Down    key: pump_down    python: phases.pump_down    timeout: 12h    ui:      components:        - key: pump_progress          type: progress          label: "Pump-down"          description: "To 1e-5 hPa"          default_value: 0          max: 100    measurements:      - name: Pump-Down        key: pumpdown        title: Chamber pressure during pump-down        x_axis:          legend: Time          unit: h        y_axis:          - legend: Pressure            key: pressure            unit: hPa            aggregations:              - type: final_hpa                unit: hPa                validators:                  - {operator: "<=", expected_value: 1.0e-5}              - type: time_to_vacuum_h                unit: h                validators:                  - {operator: "<=", expected_value: 8.0}  - name: Bakeout    key: bakeout    python: phases.bakeout    depends_on: [pump_down]    timeout: 24h    ui:      components:        - key: bake_progress          type: progress          label: "Bakeout"          description: "+60 °C, TQCM at -20 °C, until the rate is clean"          default_value: 0          max: 100    measurements:      - name: Bake        key: bake        title: TQCM frequency, outgassing rate and unit temperature through the bake        x_axis:          legend: Time          unit: h        y_axis:          - legend: TQCM frequency shift            key: tqcm            unit: Hz            aggregations:              - type: duration_h                unit: h                validators:                  - {operator: ">=", expected_value: 36.0}                  - {operator: "<=", expected_value: 72.0}              - type: total_shift_hz                unit: Hz                validators:                  - {operator: "<=", expected_value: 800.0}          - legend: Outgassing rate            key: rate            unit: Hz/h            aggregations:              - type: final_hz_per_h                unit: Hz/h                validators:                  - {operator: "<=", expected_value: 1.0}              - type: clean_window_h                unit: h                validators:                  - {operator: ">=", expected_value: 6.0}          - legend: Unit temperature            key: unit            unit: °C            aggregations:              - type: max_c                unit: °C                validators:                  - {operator: "<=", expected_value: 65.0}  - name: TVAC Cycling    key: tvac_cycling    python: phases.tvac_cycling    depends_on: [bakeout]    timeout: 24h    ui:      components:        - key: cycle_progress          type: progress          label: "Thermal vacuum cycling"          description: "4 cycles, -30 to +55 °C, 2 h dwells, powered"          default_value: 0          max: 100    measurements:      - name: Profile        key: profile        title: Unit temperature through the four cycles        x_axis:          legend: Time          unit: h        y_axis:          - legend: Unit            key: unit            unit: °C            aggregations:              - type: cycles                validators:                  - {operator: "==", expected_value: 4}              - type: min_c                unit: °C                validators:                  - {operator: "<=", expected_value: -29.0}              - type: max_c                unit: °C                validators:                  - {operator: ">=", expected_value: 54.0}                  - {operator: "<=", expected_value: 58.0}      - name: Dwells        key: dwells        title: Functional check at each hot and cold dwell        x_axis:          legend: Dwell        y_axis:          - legend: Temperature            key: temperature            unit: °C          - legend: Centroid error            key: centroid            unit: arcsec            aggregations:              - type: max_arcsec                unit: arcsec                validators:                  - {operator: "<=", expected_value: 10.0}          - legend: Power            key: power            unit: W            aggregations:              - type: max_w                unit: W                validators:                  - {operator: "<=", expected_value: 3.5}teardown:  - name: Backfill    key: backfill    python: phases.backfill    measurements:      - name: Unit Temperature At Backfill        key: unit_temp_backfill_c        unit: °C        description: Dry nitrogen only comes in with the unit above the room's dew point; a cold lens fogs.        validators:          - {operator: ">=", expected_value: 15.0}      - name: Pressure End        key: pressure_end_hpa        unit: hPa        validators:          - {operator: ">=", expected_value: 900.0}

Framework features to notice:

  1. A derived curve beside the raw one. bake records the TQCM frequency as read and the outgassing rate the phase derives from it over a 4 h window; the limits sit on the rate's final_hz_per_h and clean_window_h, and on the raw curve's total_shift_hz. The dashboard shows both.
  2. A duration window. bake.tqcm.duration_h carries a >= 36 and a <= 72: too short and the bake did not run long enough to mean anything, too long and the unit is outgassing more than its materials list allows.
  3. A count and a level. profile.unit.cycles == 4 is the ECSS requirement as a number; max_c between 54 and 58 says the hot level was reached and not overshot.
  4. A curve for the record. dwells.temperature has no aggregation; it is there so the centroid and the power at each dwell can be read against the temperature they were taken at.
  5. Very small numbers. final_hpa <= 1.0e-5 is a limit like any other; YAML reads the exponent.
  6. A teardown that gates the hardware. unit_temp_backfill_c >= 15 is not about the unit's quality; it fails the run if the chamber was backfilled onto a cold lens, which is the one mistake the run cannot undo.
  7. Phase timeouts cap at 24 h. The bake and the cycles are time-scaled in the mock; on real hardware each runs as daily segments of its phase or is read back from the chamber's logger.

Identify

The setup phase reads the tracker's firmware and temperature over its link and the chamber's pressure at ambient, and records the chamber on the unit metadata:

phases/identify.py
def identify(measurements, bench, unit, log):    """Setup: tracker telemetry, firmware, chamber at ambient with the    door closed, TQCM zeroed on the record."""    ident = bench.identify()    measurements.firmware_version = ident["firmware"]    measurements.unit_temp_start_c = ident["unit_temp_c"]    measurements.pressure_start_hpa = ident["pressure_hpa"]    unit.metadata["chamber_id"] = "TVAC-1"    log.info(f"Star tracker {unit.serial_number}: fw {ident['firmware']}, {ident['unit_temp_c']} C, chamber at {ident['pressure_hpa']:.0f} hPa")

Pump Down

One call returns the pressure curve; the phase records it and finds the time the pressure first went under the requirement:

phases/pump_down.py
import numpy as npfrom utils.recipe import P_VACUUM_HPAdef pump_down(measurements, bench, ui, log):    """Pump-down to the ECSS pressure requirement; the time it takes is a    health figure for the chamber and for the unit's outgassing load."""    cap = bench.pump_down()    ui.pump_progress = 100    t = np.array(cap["time_h"]); p = np.array(cap["pressure_hpa"])    below = np.flatnonzero(p <= P_VACUUM_HPA)    t_vac = float(t[below[0]]) if below.size else float(t[-1])    measurements.pumpdown.x_axis = cap["time_h"]    measurements.pumpdown.y_axis.pressure = cap["pressure_hpa"]    measurements.pumpdown.y_axis.pressure.aggregations.final_hpa = float(p[-1])    measurements.pumpdown.y_axis.pressure.aggregations.time_to_vacuum_h = t_vac    log.info(f"Pump-down: {p[-1]:.1e} hPa after {t[-1]:.1f} h, under {P_VACUUM_HPA:.0e} hPa at {t_vac:.2f} h")

Bakeout

The bench holds the plate at +60 °C and the crystal at -20 °C and returns the frequency curve once its own end criterion is met or the maximum is reached. The phase derives the rate over a 4 h window, finds how long the rate has been under the figure at the end, and records the total shift:

phases/bakeout.py · 33 lines
phases/bakeout.py
import numpy as npfrom utils.recipe import BAKEOUT_MAX_H, BAKEOUT_T_C, TQCM_CLEAN_WINDOW_H, TQCM_RATE_HZ_PER_H_MAXdef bakeout(measurements, bench, ui, log):    """Bake at +60 C with the TQCM at -20 C facing the unit. The crystal    frequency is the mass collected; its slope is the outgassing rate.    The bake is judged on how long the rate has been under the    programme's figure at the end, and on the total mass collected."""    cap = bench.bakeout(BAKEOUT_MAX_H)    ui.bake_progress = 100    t = np.array(cap["time_h"]); f = np.array(cap["tqcm_hz"])    # Rate over a 4 h window, Hz/h, from the frequency curve.    w = 16    rate = np.full(t.size, np.nan)    rate[w:] = (f[w:] - f[:-w]) / (t[w:] - t[:-w])    rate_curve = np.where(np.isnan(rate), rate[w], rate)    clean = rate_curve <= TQCM_RATE_HZ_PER_H_MAX    # Clean window at the end: hours since the rate last exceeded the figure.    dirty = np.flatnonzero(~clean)    clean_h = float(t[-1] - t[dirty[-1]]) if dirty.size else float(t[-1])    measurements.bake.x_axis = cap["time_h"]    measurements.bake.y_axis.tqcm = cap["tqcm_hz"]    measurements.bake.y_axis.tqcm.aggregations.duration_h = float(t[-1])    measurements.bake.y_axis.tqcm.aggregations.total_shift_hz = float(f[-1] - f[0])    measurements.bake.y_axis.rate = rate_curve.round(3).tolist()    measurements.bake.y_axis.rate.aggregations.final_hz_per_h = float(rate_curve[-1])    measurements.bake.y_axis.rate.aggregations.clean_window_h = clean_h    measurements.bake.y_axis.unit = cap["unit_c"]    measurements.bake.y_axis.unit.aggregations.max_c = float(max(cap["unit_c"]))    log.info(f"Bake {t[-1]:.1f} h at {BAKEOUT_T_C:.0f} C: TQCM {f[-1] - f[0]:.0f} Hz collected, rate {rate_curve[-1]:.2f} Hz/h at the end, clean for the last {clean_h:.1f} h (needs {TQCM_CLEAN_WINDOW_H:.0f})")

TVAC Cycling

One call returns the four cycles as a temperature curve plus the centroid error and the power read at each dwell. The phase counts the cycles as the smaller of the hot and cold dwells reached, records the profile, and writes the dwell readings as three curves against the dwell index:

phases/tvac_cycling.py · 29 lines
phases/tvac_cycling.py
import numpy as npfrom utils.recipe import CYCLES, T_ACC_MAX_C, T_ACC_MIN_Cdef tvac_cycling(measurements, bench, ui, log):    """ECSS acceptance thermal vacuum cycling: four cycles between the    acceptance levels, the tracker powered, a functional check at every    dwell: centroid of a simulated star through the window, bus power."""    cap = bench.thermal_cycles()    ui.cycle_progress = 100    u = np.array(cap["unit_c"])    hot = sum(1 for d in cap["dwell_temp_c"] if d >= T_ACC_MAX_C - 1.0)    cold = sum(1 for d in cap["dwell_temp_c"] if d <= T_ACC_MIN_C + 1.0)    cycles = int(min(hot, cold))    measurements.profile.x_axis = [round(m / 60.0, 2) for m in cap["time_min"]]    measurements.profile.y_axis.unit = cap["unit_c"]    measurements.profile.y_axis.unit.aggregations.cycles = cycles    measurements.profile.y_axis.unit.aggregations.min_c = float(u.min())    measurements.profile.y_axis.unit.aggregations.max_c = float(u.max())    measurements.dwells.x_axis = list(range(1, len(cap["dwell_temp_c"]) + 1))    measurements.dwells.y_axis.temperature = cap["dwell_temp_c"]    measurements.dwells.y_axis.centroid = cap["dwell_centroid_arcsec"]    measurements.dwells.y_axis.centroid.aggregations.max_arcsec = float(max(cap["dwell_centroid_arcsec"]))    measurements.dwells.y_axis.power = cap["dwell_power_w"]    measurements.dwells.y_axis.power.aggregations.max_w = float(max(cap["dwell_power_w"]))    log.info(f"{cycles}/{CYCLES} cycles, unit {u.min():.1f}..{u.max():.1f} C, centroid error up to {max(cap['dwell_centroid_arcsec']):.1f} arcsec, power up to {max(cap['dwell_power_w']):.2f} W at the dwells")

Two panels: on the left the unit temperature in green cycling four times between -30 and +55 °C over 23 hours with red dashed lines at the two levels and blue dots at the end of each dwell; on the right eight green bars of centroid error in arcseconds, 3.4 at the hot dwells and up to 6.1 at the cold ones, under a red dashed 10 arcsec limit.

The mock unit's cycles: four between -30 and +55 °C with 2 h dwells, the centroid error 3.4 arcsec at the hot dwells and 5.6 to 6.1 arcsec at the cold ones as the lens contracts, against a 10 arcsec limit, and the bus power under 2.8 W throughout. A tracker whose focus was set at ambient and drifts past the limit at -30 °C is a unit for the optics bench, not the chamber, and this is where it shows.

Backfill

The teardown lets dry nitrogen in only once the unit is above the room's dew point, and records both the temperature at that moment and the pressure at the end:

phases/backfill.py
def backfill(measurements, bench, log):    """Teardown: the chamber is backfilled with dry nitrogen only once    the unit is above the room's dew point; the optics must not fog."""    r = bench.backfill()    measurements.unit_temp_backfill_c = r["unit_temp_c"]    measurements.pressure_end_hpa = r["pressure_hpa"]    log.info(f"Backfill at {r['unit_temp_c']} C on the unit, chamber at {r['pressure_hpa']:.0f} hPa")

Mock Plug

TvacBench stands in for the chamber controller, the ion gauge, the QCM controller and the tracker's link. It draws a pump-down that decays to 5e-6 hPa in five hours, a bake whose outgassing rate starts at 40 Hz/h and decays with an 8 h time constant toward 0.15 Hz/h, with the bench ending the bake eight hours after the rate first went clean past the 36 h minimum, four cycles at 1.5 °C/min with 2 h dwells and a centroid error of 3.2 arcsec plus 0.055 arcsec per degree below 20 °C, and a backfill at ambient. The long phases 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.

On a real station, the class speaks Modbus to the chamber controller for the shroud, the plate and the valves, reads the ion gauge over RS-232 and the QCM controller over its serial protocol, and talks to the tracker over Ethernet for power, temperature and the centroid of the simulated star. Set TIME_SCALE = 1.0 in utils/recipe.py, run the bake and the cycles as daily segments or read them back from the chamber's logger because a phase timeout caps at 24 h, and set the TQCM rate, window and total-shift figures to the programme's contamination control plan. The phases, measurements and limits stay the same.

Run your first test in minutes