Skip to content

GNSS Receiver Sensitivity Verification

Verify drone GNSS receiver sensitivity, TTFF, and C/N0 against a controlled RF simulator scenario to catch antenna, LNA, and firmware defects.

TofuPilotFunctional TestPythonTofuPilot FrameworkGitHub
GNSS Receiver Sensitivity Verification

Introduction

GNSS in Drones

Every modern drone depends on a Global Navigation Satellite System (GNSS) receiver for position, velocity, and time. GPS alone is the legacy minimum; current production receivers track GPS + GLONASS + Galileo + BeiDou simultaneously, and high-end units add L2/L5 dual-frequency for centimeter-class RTK accuracy. The receiver chain is antenna → LNA → SAW filter → RF front-end → baseband processor, and any defect in the chain produces the same symptom: poor fix quality, slow time-to-first-fix (TTFF), and HDOP above the autopilot's arming threshold.

Close-up of a drone GNSS receiver module: ceramic patch antenna, RF shield can over the LNA and receiver chip, U.FL antenna connector at the board edge and the JST cable to the flight controller.

A typical drone GNSS module: patch antenna on top, the LNA and receiver under the shield can, and the U.FL connector where a cracked feedline or a bad crimp costs sensitivity.

A weak or noisy GNSS chain is a leading cause of drone fly-aways. ArduPilot's pre-arm safety checks block arming with a "High GPS HDOP" failure whenever HDOP exceeds 2.0 (the GPS_HDOP_GOOD parameter) in any GPS-requiring mode. Without GNSS, Return-to-Home is impossible. RTK-grade accuracy degrades if antenna-system gain drops below the 17 dB minimum u-blox specifies in the ZED-F9P integration manual, or if the feedline cracks. The 5.8 GHz VTX radiates wideband power-amplifier noise that desenses the GNSS front end at L1 (1575.42 MHz) and L2 (1227.60 MHz), so every airframe ships with a receiver whose live noise floor is unknown unless verified.

Test Purpose

Per-unit GNSS sensitivity verification produces a fingerprint covering:

  • Cold-start TTFF (no almanac, no ephemeris, no position priors) at standardized signal power
  • Hot-start TTFF (valid aiding data) for re-fix testing
  • Sensitivity floor: the signal power at which the receiver loses lock
  • C/N₀ per satellite across the power sweep, where band-skewed dropouts reveal matching network defects
  • Position and velocity accuracy at reference power
  • HDOP at lock

The limits in this template sit inside the margins the u-blox NEO-M9N datasheet specifies (cold start 24-29 s, hot start 2 s, tracking sensitivity -167 dBm), so a healthy receiver passes with room to spare and a marginal RF chain does not.

Mean C/N0 versus simulator output power: 42 dB-Hz at -130 dBm rolling off 0.35 dB per dB across the 3 dB steps down to -163 dBm, with the 38 dB-Hz aggregation limit and the -161 dBm sensitivity floor marked.

The sweep as the mock simulator produces it: 42 dB-Hz at -130 dBm, a steady roll-off across the 3 dB steps, and a 3D fix held down to -161 dBm, 1 dB inside the limit.

Per-unit testing eliminates the leading GNSS-related field failures: dead LNA from reversed mount, feedline cracks from rough assembly, sub-spec antenna patterns from carbon-fiber frame shielding, and firmware misconfigs (constellation mask wrong, baud mismatch).

Beyond the RF content, this template demonstrates four framework capabilities that fit GNSS testing naturally: automatic phase retry for the flaky first cold start, a regex validator pinning the receiver firmware to the production release line, sub-unit traceability linking the GNSS module serial to the airframe, and a phase timeout bounding the power sweep.

Equipment & Setup

To implement GNSS sensitivity verification on a production line, the following are required:

  • A GNSS RF simulator capable of multi-constellation scenarios with calibrated output power
  • A shielded test environment (Faraday tent or anechoic chamber) to block ambient GNSS leakage
  • A programmable step attenuator for the RF path
  • The Device Under Test (DUT) with the production-target GNSS module and antenna
  • A TofuPilot Framework procedure to script the simulator, sweep power, collect NMEA / UBX telemetry, and validate metrics
  • The TofuPilot Dashboard to log per-unit results and trend supplier quality

Hardware Components

GNSS RF Simulator

The Spirent GSS6300M is the production reference: 36-channel, L1/E1 multi-GNSS, IEEE-488 remote control, continuous-run scenarios for ATE. For higher-end labs the Safran Skydel GSG-7 / GSG-8 (GPU + SDR, 1000 Hz simulation iteration rate, IQ playback) covers L1/L2/L5 with scripted scenarios. For budget production lines, the Racelogic LabSat 3 Wideband is a record-replay box capable of 3 channels × 56 MHz that can play a recorded factory roof reference scenario at calibrated power. All three are quote-based; budget roughly $10k for the LabSat class up to high five figures for the Spirent and Skydel class.

GNSS test bench: drone inside a shielded RF tent, GNSS signal simulator and programmable attenuator cabled to the tent bulkhead, and the production test computer showing the C/N0 roll-off curve.

The bench: simulator and step attenuator outside the tent, the RF path through the bulkhead, the DUT inside, and the test computer driving the sweep.

Shielded Environment

A Faraday tent (Select Fabricators, JRE Test) is the cost-effective production option vs a fixed anechoic chamber. Specify at least 60 dB isolation at L1 so no ambient GNSS leaks in and corrupts the scenario. A fixed ETS-Lindgren anechoic chamber is the higher-end alternative for OTA antenna pattern testing.

Attenuator

A JFW 50P-2014 solid-state programmable step attenuator: 0-95 dB in 1 dB steps, 200-6000 MHz, USB or TTL controlled (the 50P-2013 is the 0-63 dB variant). Insertion accuracy ±0.5 dB up to 90 dB. Calibration certificate per channel-frequency required for production traceability.

Reference Receiver

A u-blox EVK-NEO-M9N runs the same scenario in parallel as a golden reference. Drift in the simulator output power shows up as a divergence between the reference and the DUT, isolating simulator faults from DUT faults.

Custom Firmware

The DUT GNSS module runs production firmware with its normal constellation mask, sample rate, and dynamic model. The test station receives UBX (u-blox) or proprietary protocol messages and parses C/N₀, position, HDOP, fix status. No special firmware build is needed.

Test Procedure

Overview

After mounting the DUT inside the Faraday tent and connecting the simulator chain, the procedure runs:

  1. Verify the reference receiver locks at -130 dBm (sanity check on the simulator).
  2. Cold-start the DUT (clear ephemeris, position, time) and measure TTFF; retry automatically if the first fix exceeds the limit.
  3. Hot-start the DUT and measure TTFF.
  4. Sweep power down in 3 dB steps, log mean C/N₀ per step, and find the sensitivity floor.
  5. Validate every metric against its declared limits.
  6. Stream results to TofuPilot for traceability and analytics.

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
verify_reference.py
cold_start.py
hot_start.py
power_sweep.py
plugs
simulator.py
receiver.py
utils
sensitivity.py
README.md
pyproject.toml

The mock plugs replay a deterministic scenario so the procedure runs without hardware; swap them for Spirent/Skydel (pyvisa) and pyubx2 implementations on a real bench. Run it with tofupilot run . --no-tui --no-kiosk --json. You can find the full source on GitHub.

The Procedure File

procedure.yaml declares the unit, the sub-unit, the equipment plugs, and the phases with their measurements and limits. This is the exact file the template ships, verified end to end with the CLI:

procedure.yaml
name: GNSS Receiver Sensitivity Verificationversion: 0.1.0description: Verifies GNSS receiver cold/hot-start TTFF, C/N0 tracking across a calibrated power sweep, and sensitivity floor against a simulator scenario.unit:  auto_identify: true  serial_number:    default_value: "DRN-00042"  part_number:    default_value: "FC-GNSS-M9N"  sub_units:    - label: "GNSS Module"      serial_number:        placeholder: "Scan GNSS module barcode"        pattern: "^GNSS-\\d{6}$"        default_value: "GNSS-104217"plugs:  - name: GNSS Simulator    description: Mock Spirent-class simulator with calibrated output power and programmable step attenuator.    python: plugs.simulator:GnssSimulator    key: simulator  - name: Receiver Telemetry    description: Mock UBX telemetry stream from the receiver under test.    python: plugs.receiver:ReceiverTelemetry    key: receivermain:  - name: Verify Reference Lock    key: verify_reference    python: phases.verify_reference    measurements:      - name: Reference Locked        key: reference_locked        validators:          - operator: "=="            expected_value: true  - name: Cold Start TTFF    key: cold_start    python: phases.cold_start    depends_on: [verify_reference]    retry:      limit: 3      delay: 1s    then:      fail: retry    measurements:      - name: Cold Start TTFF        key: cold_start_ttff_s        unit: s        description: Median time to first 3D fix from cleared ephemeris at -130 dBm.        validators:          - operator: "<="            expected_value: 30.0      - name: Receiver Firmware        key: firmware_version        description: Firmware reported by UBX-MON-VER, pinned to the production release line.        validators:          - operator: matches            expected_value: "^SPG \\d+\\.\\d{2}$"  - name: Hot Start TTFF    key: hot_start    python: phases.hot_start    depends_on: [cold_start]    measurements:      - name: Hot Start TTFF        key: hot_start_ttff_s        unit: s        validators:          - operator: "<="            expected_value: 5.0  - name: Power Sweep    key: power_sweep    python: phases.power_sweep    depends_on: [hot_start]    timeout: 60s    measurements:      - name: CN0 vs Power        key: cn0_vs_power        title: Average C/N0 vs Simulator Power        description: Mean C/N0 across tracked satellites at each simulator output power step.        x_axis:          legend: Simulator Power          unit: dBm        y_axis:          - legend: Mean C/N0            key: cn0            unit: dB-Hz            aggregations:              - type: max                validators:                  - operator: ">="                    expected_value: 38.0      - name: Sensitivity Floor        key: sensitivity_floor_dbm        unit: dBm        description: Lowest simulator power with a held 3D fix.        validators:          - operator: "<="            expected_value: -160.0      - name: HDOP At Lock        key: hdop_at_lock        validators:          - operator: "<"            expected_value: 1.4

Three framework features to notice in the YAML:

  1. Sub-unit traceability: the sub_units block collects the GNSS module serial (with a regex pattern on the barcode) and links it to the airframe unit. In Python the value is available as unit.sub_units.gnss_module; the framework snake_cases the label into the access key.
  2. Retry on the cold start: then: {fail: retry} with retry: {limit: 3, delay: 1s}. A fresh board with a blank aiding flash routinely blows the 30 s TTFF limit once; instead of failing the unit, the framework re-runs the phase up to 3 times with a 1 s pause. Every attempt is preserved and visible on the dashboard with its own measurements.
  3. Regex validator: the matches operator pins firmware_version to the SPG x.yy release line (SPG 4.04 is the NEO-M9N production firmware), catching mis-flashed receivers with a one-line validator instead of Python string handling.

Note the hdop_at_lock < 1.4 limit is this template's production margin, chosen well inside ArduPilot's arming threshold of 2.0 so a shipped unit never sits at the edge of the autopilot's own check.

Reference Lock Phase

The opening phase sanity-checks the simulator and logs which physical module this airframe carries, reading the sub-unit serial collected at identification:

phases/verify_reference.py
def verify_reference(measurements, simulator, log, unit):    log.info(f"Verifying scenario for unit {unit.serial_number}")    module_sn = unit.sub_units.gnss_module    log.info(f"GNSS module sub-unit: {module_sn}")    simulator.set_attenuation(0)    measurements.reference_locked = simulator.reference_locked()

Cold Start Phase

The phase writes its measurements and lets the declared validators decide the outcome. When cold_start_ttff_s exceeds 30 s, the measurement fails, the phase fails, and the then: {fail: retry} rule re-runs it:

phases/cold_start.py
def cold_start(measurements, receiver, log, unit):    log.info(f"Cold-starting module {unit.sub_units.gnss_module}")    receiver.clear_aiding_data()    ttff = receiver.cold_start_ttff_s()    log.info(f"Cold start TTFF: {ttff:.1f} s")    if ttff > 30.0:        log.warning("TTFF above limit, framework will retry this phase")    measurements.cold_start_ttff_s = ttff    measurements.firmware_version = receiver.firmware_version()

In the JSON event stream the retry is explicit: attempt 1 finishes FAIL with measurement cold_start_ttff_s failed: <= 30.0, attempt 2 finishes PASS one second later.

Cold start time series: simulator held at -130 dBm, fix type rising from no fix to 3D at 41.8 s on the first attempt which fails the 30 s limit, then at 18.4 s on the automatic retry, with the satellites used climbing from 0 to 12 on each attempt.

The two cold-start attempts of the mock receiver at -130 dBm: the first 3D fix lands at 41.8 s and fails the limit, the framework clears aiding data and re-runs the phase, the second fix lands at 18.4 s.

Power Sweep Phase

The sweep steps attenuation up, records mean C/N₀ per power step into the multi-dimensional chart, then steps power down 1 dB at a time to find the sensitivity floor. The timeout: 60s on the phase bounds the whole sweep, including plug calls, so a hung simulator cannot stall the line:

phases/power_sweep.py
from utils.sensitivity import find_sensitivity_floordef power_sweep(measurements, simulator, receiver, log):    powers = []    cn0s = []    attenuation = 0    while attenuation <= 35:        simulator.set_attenuation(attenuation)        power = simulator.output_power_dbm()        powers.append(power)        cn0s.append(simulator.mean_cn0_at(power))        attenuation += 3    measurements.cn0_vs_power.x_axis = powers    measurements.cn0_vs_power.y_axis.cn0 = cn0s    measurements.cn0_vs_power.y_axis.cn0.aggregations.max = max(cn0s)    floor = find_sensitivity_floor(simulator, start_dbm=-150.0, stop_dbm=-165.0)    log.info(f"Sensitivity floor: {floor:.0f} dBm")    measurements.sensitivity_floor_dbm = floor    measurements.hdop_at_lock = receiver.hdop()

The floor search lives in a plain utility module:

utils/sensitivity.py
def find_sensitivity_floor(simulator, start_dbm, stop_dbm, step_db=1.0):    """Step power down until the receiver drops the 3D fix.    Returns the lowest power at which the fix was still held.    """    power = start_dbm    last_held = start_dbm    while power >= stop_dbm:        if not simulator.fix_held_at(power):            break        last_held = power        power -= step_db    return last_held

The C/N₀ vs power curve should be near-linear above the floor with slope ~1 dB/dB. Departure from linearity at high power indicates LNA saturation (rare); departure at low power indicates the noise floor of the receiver chain.

Heatmap of C/N0 per satellite versus simulator power from -130 to -165 dBm: twelve satellites tracked uniformly down to the -161 dBm floor, one satellite dropping about 10 dB early and lost at -146 dBm, flagged as an antenna pattern null.

Per-satellite view of the same sweep on a real bench: a healthy chain loses satellites together at the floor, while one satellite dropping 10 dB early points at an antenna pattern null or a frame shadowing that elevation.

Mock Plugs

The template ships mock plugs that replay a deterministic scenario, so the whole procedure runs green without hardware. The receiver mock makes the first cold start deliberately slow to exercise the retry path:

plugs/receiver.py
class ReceiverTelemetry:    def __init__(self):        self._cold_start_attempts = 0    def clear_aiding_data(self):        print("UBX-CFG-RST (navBbrMask 0xFFFF) sent, cold start forced")    def cold_start_ttff_s(self):        self._cold_start_attempts += 1        if self._cold_start_attempts == 1:            return 41.8  # blank aiding flash on first boot        return 18.4    def hot_start_ttff_s(self):        return 2.1    def firmware_version(self):        return "SPG 4.04"    def hdop(self):        return 0.8

On real u-blox hardware, forcing a true cold start means sending UBX-CFG-RST with navBbrMask 0xFFFF to clear ephemeris, almanac, position, and time from battery-backed RAM (UBX-CFG-CFG only manages configuration, not aiding data). Plugs persist across retry attempts, which is what lets the mock model "slow first fix, fast second fix" with a simple counter. On a real bench, swap this class for a pyserial + pyubx2 implementation; the phases stay unchanged.

Run your first test in minutes