Skip to content

Barometric Altimeter Pressure Calibration

Calibrate a drone MEMS barometer for offset, scale, and thermal drift across the pressure-temperature envelope to ensure accurate altitude hold.

TofuPilotFactory CalibrationPythonTofuPilot FrameworkGitHub
Barometric Altimeter Pressure Calibration

Introduction

Barometer Overview

A drone uses a MEMS barometric pressure sensor to estimate altitude. The sensor measures absolute atmospheric pressure at 50-100 Hz, and the autopilot maps pressure to altitude using the ISA (International Standard Atmosphere) model:

h = 44330 · (1 − (P/P₀)^0.190284) [m]

Near sea level, 1 Pa ≈ 8.3 cm of altitude. GNSS vertical accuracy is 3-5 m and slow; baro is fast, low-noise, and bounded, but only if its offset, scale, and thermal drift are characterized at the factory. Altitude hold, terrain following, geo-fence ceiling enforcement, and EKF vertical fusion all rely on a calibrated baro.

Flight-controller PCB with the metal-can barometer package and its pressure port highlighted, next to a 3 mm scale bar.

The metal-can barometer package and its pressure port on the flight-controller board.

Three sensors dominate drone production: the TE MS5611 (0.012 mbar resolution at highest oversampling, 24-bit ΔΣ, factory PROM coefficients), the Bosch BMP388 (±8 Pa relative accuracy ≈ ±0.6 m, ±50 Pa absolute, TCO ±0.75 Pa/K, default for Betaflight and ArduPilot), and the Infineon DPS310 (±0.002 hPa precision ≈ 2 cm in high-precision mode, ±0.06 hPa relative accuracy, favored on PX4 and DJI). Each has different uncompensated drift, and temperature compensation is mandatory because the silicon diaphragm's Young's modulus drops with temperature, CTE mismatch between Si and the LGA package strains the membrane, and the ASIC adds its own offset and gain drift.

Calibration Purpose

The procedure characterizes per-DUT pressure sensor performance across the pressure × temperature envelope and produces a compensation model:

  • Offset at reference pressure and temperature (typically 100 kPa, 25 °C)
  • Scale factor relating raw reading to true pressure
  • Temperature compensation coefficients (linear or bivariate quadratic)
  • Noise floor (Allan deviation at τ = 1 s, 10 s, 100 s)
  • Pressure-altitude conversion error after applying the compensation model

DUT minus reference pressure across 80-120 kPa at 0, 25 and 50 °C: before compensation a 140-195 Pa temperature-dependent offset, after the bivariate quadratic fit a flat residual of 0.30 Pa RMS within the ±5 Pa limit.

Residual of the mock DUT over the full envelope before and after the compensation fit.

Without per-unit calibration, uncompensated drift can reach tens of Pa across 0-50 °C, enough to push the altitude estimate out of the EKF baro innovation gate (EKF2_BARO_GATE on PX4) and cause altitude jumps during takeoff in cold weather. With calibration, post-correction residual stays below 5 Pa (40 cm altitude) across the entire envelope.

Equipment & Setup

To implement barometer calibration on a production line, the following are required:

  • A traceable pressure reference (controller or transducer) with ±0.01% FS class accuracy
  • A climate chamber covering 0-50 °C with pressure feedthrough
  • A sealed test cell plumbed to the pressure controller, with electrical breakout for the DUT
  • The Device Under Test (DUT): autopilot board with baro mounted
  • A TofuPilot Framework procedure to coordinate chamber, pressure, and DUT, fit the compensation model, and validate residuals
  • The TofuPilot Dashboard to log per-unit coefficients and trend population statistics

Hardware Components

Pressure Reference

The Druck DPI 612 pFlex is the bench-grade reference: accuracy from 0.005% FS (module-dependent), −95 to 2000 kPa range, integrated pump and electrical port. For high-volume production lines, the Mensor CPC4000 is the standard: 0.02% IS-50 accuracy over a −1 to 210 bar span of available ranges, with parallel-port multi-DUT manifolds. Both are ISO 17025 traceable; recertification is typically annual (Mensor specifies a 365-day calibration interval).

Production pressure calibration rack: a pressure controller feeding a manifold that splits to four sealed test cells, each with its own cable to the test station.

One pressure controller feeding four sealed cells through a manifold: batch parallelism amortizes the 45-minute cycle.

For portable / line-side spot checks, the Fluke 3130-G2M (−80 kPa to 2 MPa, ±0.025% rdg + 0.01% FS, integrated pump) is the field reference. A reference transducer sits inside the test cell, not outside the line, to eliminate pneumatic lag.

Climate Chamber

The Vötsch VT4034 (335 L workspace, −40 to +180 °C) is the production standard for temperature coverage. The chamber needs a pressure feedthrough bulkhead with a pneumatic and an electrical pass-through. For lower volume, a Memmert TTC256 environmental test chamber (256 L, −42 to +190 °C) or a custom Peltier cell handles the narrower 0-50 °C drone envelope at lower cost.

Test Cell

A machined aluminum sealed cell with an O-ring lid, a 4-pin pogo block for electrical contact, and a pressure tap to the controller. Reference transducer mounted inside the cell. Cells must be leak-tested before each shift: a 1 kPa leak per minute is enough to invalidate the calibration.

Custom Firmware

The DUT autopilot must expose a raw baro streaming mode at the maximum native rate (typically 50-100 Hz for BMP388, 200 Hz for DPS310). The stream includes:

  • Timestamp (ms)
  • Raw pressure (Pa, ADC counts with known scale, or already-corrected Pa from the sensor's factory PROM, depending on the chip)
  • Raw temperature (°C from the sensor's built-in temperature channel)

The autopilot must not apply on-board temperature compensation during the calibration run, otherwise the calibration captures the sensor's own thermal compensation rather than the residual error.

Test Procedure

Overview

After mounting the DUT in the sealed cell and closing the chamber, the procedure runs:

  1. Sweep pressure 80 → 120 kPa at 0, 25, and 50 °C, logging DUT, reference, and temperature.
  2. Fit the per-DUT bivariate quadratic compensation model and validate the residuals.
  3. Dwell at 100 kPa / 25 °C and compute the Allan deviation at 1 s, 10 s, and 100 s.
  4. Write the coefficients to DUT non-volatile storage and verify the read-back.
  5. Stream results to TofuPilot for traceability and analytics.

Total cycle is roughly 45 minutes per DUT on real hardware: three temperature soaks with stabilization, a ~10 minute pressure ramp at each, and the 10 minute noise dwell, amortized by batch parallelism (4-8 cells per chamber). The mock chamber compresses all of this into seconds so the template runs instantly; the phase structure and limits are what carry over to hardware.

This template exercises several framework capabilities on purpose: plug config and shared plug scope, custom aggregation types for the Allan deviation with their own validators, a JSON measurement carrying the coefficient set, a phase timeout, parallel phases via depends_on, and previous-results injection.

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
pressure_sweep.py
compensation_fit.py
noise_characterization.py
save_calibration.py
plugs
chamber.py
utils
allan.py
pyproject.toml

You can find the full source on GitHub. The mock chamber plug synthesizes the DUT error model in code, so the procedure runs end to end without hardware:

tofupilot run .

The Procedure File

procedure.yaml declares the unit (with revision and batch defaults), the chamber plug with its config and scope, and four phases. compensation_fit and noise_characterization both depend only on the sweep, so the engine runs them in parallel; save_calibration waits for both. This is the exact file from the repository:

procedure.yaml
name: Barometric Altimeter Pressure Calibrationversion: 0.1.0description: Characterizes a MEMS barometer across the pressure-temperature envelope and fits a per-unit compensation model.unit:  auto_identify: true  serial_number:    default_value: "SN-BARO-0001"  part_number:    default_value: "FC-MAIN-V2"  revision_number:    default_value: "Rev C"  batch_number:    default_value: "BATCH-2026-031"plugs:  - name: Climate Chamber    key: chamber    description: Chamber + pressure controller. Execution scope (one instance shared by all slots). Newer engines add scope station to hold the chamber session across executions on a station daemon.    python: plugs.chamber:MockChamber    scope: all    config:      address: "192.168.1.60"main:  - name: Pressure Sweep    key: pressure_sweep    python: phases.pressure_sweep    timeout: 5m    measurements:      - name: Sweep Samples        key: sweep_samples        validators:          - operator: ">="            expected_value: 1800  - name: Compensation Fit    key: compensation_fit    python: phases.compensation_fit    depends_on: [pressure_sweep]    measurements:      - name: Residual RMS        key: residual_rms        unit: Pa        description: RMS of the post-fit residual over the full P x T envelope.        validators:          - operator: "<="            expected_value: 5.0      - name: Residual Max        key: residual_max        unit: Pa        validators:          - operator: "<="            expected_value: 12.0      - name: Scale Factor Error        key: scale_error_pct        unit: "%"        validators:          - operator: "<="            expected_value: 0.5      - name: Compensation Coefficients        key: compensation_coefficients        description: Bivariate quadratic model written to the DUT, stored as JSON for traceability.  - name: Noise Characterization    key: noise_characterization    python: phases.noise_characterization    depends_on: [pressure_sweep]    measurements:      - name: Noise Capture        key: noise_capture        unit: Pa        description: 10 Hz dwell at 100 kPa / 25 C. Allan deviation is validated per averaging time.        aggregations:          - type: allan_1s            unit: Pa            validators:              - operator: "<="                expected_value: 0.5          - type: allan_10s            unit: Pa            validators:              - operator: "<="                expected_value: 0.3          - type: allan_100s            unit: Pa            validators:              - operator: "<="                expected_value: 0.2  - name: Save Calibration    key: save_calibration    python: phases.save_calibration    depends_on: [compensation_fit, noise_characterization]    measurements:      - name: Readback OK        key: readback_ok        validators:          - operator: "=="            expected_value: true

Three details worth noticing. The plug takes a config mapping: the chamber address lives in the YAML, not in Python. The plug's scope: all shares one chamber instance across all slots of an execution; on a station daemon, newer engines accept scope: station to keep the session open between executions so back-to-back units skip the reconnect. And the sweep phase carries a timeout: 5m: if the chamber hangs mid-ramp, the phase ends with a timeout outcome instead of blocking the line indefinitely.

The Chamber Plug

The mock injects a known error model (offset, thermal terms, scale, noise) that the fit must recover. It also caches the sweep: the plug instance persists across phases, so the fit phase fetches the exact dataset the sweep phase captured instead of re-running a sweep that costs 45 minutes on real hardware. Swap the class for one driving your real chamber and pressure controller over SCPI; the interface stays identical:

plugs/chamber.py
import mathimport randomclass MockChamber:    """Simulated climate chamber + pressure controller + DUT breakout.    Declared with `scope: all` so one instance is shared by all slots.    On a station daemon, newer engines accept `scope: station` to keep    the chamber session open across executions.    """    # Injected DUT error model, unknown to the fit    OFFSET = 118.0        # Pa at 25 C    T_LIN = 0.8           # Pa per K    T_QUAD = 0.012        # Pa per K^2    SCALE = 1.0004    NOISE_PA = 0.3    def __init__(self, address):        self.address = address        self._saved = None        self._last_sweep = None        random.seed(388)        print(f"Chamber connected at {address}")    def sweep(self, temps_c, p_start, p_end, points_per_temp):        """Ramp pressure at each temperature. Returns flat sample lists.        The capture is also cached on the plug so later phases can fetch        the same dataset with last_sweep() instead of re-running a sweep        that takes ~45 minutes on real hardware.        """        ref, dut, temp = [], [], []        for t_c in temps_c:            for i in range(points_per_temp):                p_true = p_start + (p_end - p_start) * i / (points_per_temp - 1)                dt = t_c - 25.0                err = self.OFFSET + self.T_LIN * dt + self.T_QUAD * dt * dt                p_dut = self.SCALE * p_true + err + random.gauss(0, self.NOISE_PA)                ref.append(p_true)                dut.append(p_dut)                temp.append(t_c + random.gauss(0, 0.05))        self._last_sweep = {"ref": ref, "dut": dut, "temp": temp}        return self._last_sweep    def last_sweep(self):        """Return the cached capture from the most recent sweep."""        if self._last_sweep is None:            raise RuntimeError("No sweep has been run yet")        return self._last_sweep    def dwell(self, seconds, rate_hz):        """Hold 100 kPa / 25 C and stream DUT noise samples."""        n = int(seconds * rate_hz)        base = 100_000.0        drift = 0.0        out = []        for i in range(n):            drift += random.gauss(0, 0.004)          # slow random walk            out.append(base + drift + random.gauss(0, self.NOISE_PA))        return out    def write_calibration(self, coefficients):        self._saved = dict(coefficients)        return True    def read_calibration(self):        return self._saved

Pressure Sweep

At each temperature setpoint, ramp pressure 80 → 120 kPa while logging DUT and reference. On real hardware the slow ramp ensures the sealed cell tracks the controller without pneumatic lag:

phases/pressure_sweep.py
TEMPS_C = [0.0, 25.0, 50.0]P_START = 80_000.0P_END = 120_000.0POINTS = 600def pressure_sweep(chamber, measurements, log):    log.debug(f"Sweeping {P_START/1000:.0f}-{P_END/1000:.0f} kPa at {TEMPS_C} C")    data = chamber.sweep(TEMPS_C, P_START, P_END, POINTS)    n = len(data["ref"])    measurements.sweep_samples = n    log.info(f"Captured {n} samples across {len(TEMPS_C)} temperature soaks")

Pressure ramp from 80 to 120 kPa at the 50 °C soak: controller setpoint, in-cell reference transducer tracking it, and the DUT raw reading offset by about 180-195 Pa.

One ramp of the sweep at the 50 °C soak; the inset shows the DUT error the fit has to absorb.

Compensation Model Fit

For BMP388 and DPS310 (and MS5611 if rejecting factory PROM), fit a bivariate quadratic P_corr = a₀ + a₁·P_raw + a₂·T + a₃·P_raw·T + a₄·T². The fit consumes the sweep phase's dataset through the plug's last_sweep() cache, so both phases operate on the same samples. The coefficient set is recorded as a JSON measurement: the whole model travels with the run, queryable per unit years later:

phases/compensation_fit.py
import numpy as npdef compensation_fit(chamber, measurements, log):    # Fetch the dataset the Pressure Sweep phase captured. The plug caches    # it, so the fit consumes the exact samples that were counted, and a    # real bench never pays for a second 45-minute sweep.    data = chamber.last_sweep()    p_raw = np.asarray(data["dut"])    p_ref = np.asarray(data["ref"])    t = np.asarray(data["temp"])    # P_corr = a0 + a1*P_raw + a2*T + a3*P_raw*T + a4*T^2    design = np.column_stack(        [np.ones_like(p_raw), p_raw, t, p_raw * t, t**2]    )    coeffs, *_ = np.linalg.lstsq(design, p_ref, rcond=None)    residuals = design @ coeffs - p_ref    rms = float(np.sqrt(np.mean(residuals**2)))    peak = float(np.max(np.abs(residuals)))    scale_error = abs(coeffs[1] - 1.0) * 100.0    log.debug(f"Fit coefficients: {coeffs}")    log.info(f"Post-fit residual {rms:.2f} Pa RMS, {peak:.2f} Pa max")    if rms > 2.5:        log.warning(f"Residual RMS {rms:.2f} Pa above the 2.5 Pa watch level")    measurements.residual_rms = rms    measurements.residual_max = peak    measurements.scale_error_pct = float(scale_error)    measurements.compensation_coefficients = {        "model": "bivariate_quadratic",        "a0": float(coeffs[0]),        "a1": float(coeffs[1]),        "a2": float(coeffs[2]),        "a3": float(coeffs[3]),        "a4": float(coeffs[4]),    }

Allan Deviation with Custom Aggregations

The dwell at 100 kPa / 25 °C feeds an Allan deviation computation per IEEE 952-2020 conventions adapted for barometers. Excessive 1 s σ points to a noisy ASIC; high 100 s σ points to a leaking package or contaminated port.

Aggregation types are free strings, so the three averaging times are declared as allan_1s, allan_10s, and allan_100s on the noise_capture array measurement, each with its own validator. Python computes the values; the engine enforces the limits:

utils/allan.py
import numpy as npdef allan_deviation(samples, rate_hz, tau_s):    """Non-overlapping Allan deviation at one averaging time."""    samples = np.asarray(samples, dtype=float)    m = int(tau_s * rate_hz)    n_clusters = len(samples) // m    if n_clusters < 3:        raise ValueError(f"Need >= 3 clusters at tau={tau_s}s, got {n_clusters}")    means = samples[: n_clusters * m].reshape(n_clusters, m).mean(axis=1)    return float(np.sqrt(0.5 * np.mean(np.diff(means) ** 2)))
phases/noise_characterization.py
import numpy as npfrom utils.allan import allan_deviationRATE_HZ = 10DWELL_S = 600def noise_characterization(chamber, measurements, log):    log.debug(f"Dwelling {DWELL_S}s at 100 kPa / 25 C for the noise capture")    samples = chamber.dwell(DWELL_S, RATE_HZ)    detrended = list(np.asarray(samples) - np.mean(samples))    measurements.noise_capture = detrended    aggs = measurements.noise_capture.aggregations    aggs.allan_1s = allan_deviation(samples, RATE_HZ, 1)    aggs.allan_10s = allan_deviation(samples, RATE_HZ, 10)    aggs.allan_100s = allan_deviation(samples, RATE_HZ, 100)    log.info(        f"Allan deviation: {aggs.allan_1s:.3f} Pa @1s, "        f"{aggs.allan_10s:.3f} Pa @10s, {aggs.allan_100s:.3f} Pa @100s"    )

Allan deviation log-log plot of the DUT pressure noise from the 600 s dwell: white-noise slope at short averaging times, minimum near 10 s, random-walk rise beyond, with allan_1s, allan_10s and allan_100s marked against their limits.

Allan deviation of the mock dwell: white noise averages down until the slow random walk takes over past 10 s.

Saving Calibration to DUT

The save phase reads the fitted model from the fit phase through previous-results injection (the parameter named compensation_fit exposes that phase's measurements), writes it through the plug, and verifies the read-back:

phases/save_calibration.py
def save_calibration(chamber, compensation_fit, measurements, log):    coeffs = compensation_fit.compensation_coefficients    chamber.write_calibration(coeffs)    readback = chamber.read_calibration()    ok = readback == coeffs    if not ok:        log.error("Calibration readback mismatch, refusing to pass the unit")    measurements.readback_ok = ok    log.info("Coefficients written to DUT non-volatile storage and verified")

On a real product the write targets the firmware's own calibration store: a dedicated flash page, EEPROM, or the sensor vendor's PROM, read by the driver at boot. On PX4, thermal compensation lives in the TC_B* parameters produced by the sensor thermal calibration workflow, and the runtime applies it before pressure reaches the EKF baro innovation gate (EKF2_BARO_GATE; EK3_ALT_M_NSE is the ArduPilot equivalent noise setting). ArduPilot's runtime baro parameters (BARO1_GND_PRESS, GND_ALT_OFFSET) are not a factory-calibration store: the ground pressure is recalculated at every baro calibration and GND_ALT_OFFSET resets, so per-unit compensation belongs in your own firmware storage rather than those fields. After write, reboot the DUT and read back to confirm persistence.

Run your first test in minutes