
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.

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.

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.

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:
- Verify the reference receiver locks at -130 dBm (sanity check on the simulator).
- Cold-start the DUT (clear ephemeris, position, time) and measure TTFF; retry automatically if the first fix exceeds the limit.
- Hot-start the DUT and measure TTFF.
- Sweep power down in 3 dB steps, log mean C/N₀ per step, and find the sensitivity floor.
- Validate every metric against its declared limits.
- 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
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:
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.4Three framework features to notice in the YAML:
- Sub-unit traceability: the
sub_unitsblock collects the GNSS module serial (with a regexpatternon the barcode) and links it to the airframe unit. In Python the value is available asunit.sub_units.gnss_module; the framework snake_cases the label into the access key. - Retry on the cold start:
then: {fail: retry}withretry: {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. - Regex validator: the
matchesoperator pinsfirmware_versionto theSPG x.yyrelease 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:
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:
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.

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:
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:
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_heldThe 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.

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:
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.8On 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.