Custom EKF3 Position/Wind Reset for GPS-Denied Flight (ArduPlane 4.4.3)

Custom EKF3 Position/Wind Reset for GPS-Denied Flight (ArduPlane 4.4.3)

Hi everyone,

I’ve been working on GPS-denied flight for a VTOL QuadPlane (~15 kg, CUAV V5+) and wanted to share a custom EKF3 modification that solved the problem for us. The code is open, links at the bottom.

The problem

Stock EKF3 can’t initialize position without GPS. If there’s no GPS from boot, origin never gets set, the filter stays in AID_NONE, and you get:

  • No position reporting through AHRS
  • No wind state estimation (inhibitWindStates = true)
  • Zero-velocity fusion fighting actual flight velocity
  • No auto/guided modes

We tried feeding coordinates through MAVLink GPS input first, but that brought its own set of problems: EKF warmup conflicts, core disagreements, inability to initialize cleanly without a real GPS fix at boot. So we went deeper and modified EKF3 directly.

What we added

Two new functions in the EKF3 core, called through custom MAVLink COMMAND_INT commands:

forcePositionReset() (command 43210)

Params: lat (degE7), lon (degE7), accuracy (meters)

What it does internally:

  1. Sets origin if validOrigin is false (critical for cold start)
  2. Converts lat/lon to NE offset, calls ResetPositionNE()
  3. Updates lastKnownPositionNE so AID_NONE doesn’t snap back to (0,0)
  4. Sets position covariance from accuracy parameter: P[7][7] = sq(posAccuracy)
  5. Switches PV_AidingMode to AID_ABSOLUTE
  6. Sets _has_forced_position flag (used in Outputs and PosVelFusion)

Key implementation in AP_NavEKF3_PosVelFusion.cpp:

bool NavEKF3_core::forcePositionReset(const Location &loc, float posAccuracy)
{
    if (!validOrigin) {
        if (!setOrigin(loc)) return false;
    }

    Vector2F posNE = EKF_origin.get_distance_NE_ftype(loc);
    ResetPositionNE(posNE.x, posNE.y);

    lastKnownPositionNE.x = posNE.x;
    lastKnownPositionNE.y = posNE.y;

    P[7][7] = sq(ftype(posAccuracy));
    P[8][8] = sq(ftype(posAccuracy));

    PV_AidingMode = AID_ABSOLUTE;

    // First call: velocity is zero, open covariance for TAS fusion
    // Repeat calls: velocity already correct, don't touch
    const bool firstForce = !_has_forced_position;
    _has_forced_position = true;
    if (firstForce) {
        zeroRows(P,4,5);
        zeroCols(P,4,5);
        P[4][4] = sq(ftype(25.0f));
        P[5][5] = sq(ftype(25.0f));
    }

    posTimeout = false;
    velTimeout = false;
    lastPosPassTime_ms = imuSampleTime_ms;
    lastVelPassTime_ms = imuSampleTime_ms;

    return true;
}

Applied to all cores through NavEKF3::forcePositionReset() which iterates over core[i].

forceWindReset() (command 43211)

Params: windN (m/s), windE (m/s), accuracy (m/s)

bool NavEKF3_core::forceWindReset(float windN, float windE, float windAccuracy)
{
    stateStruct.wind_vel.x = windN;
    stateStruct.wind_vel.y = windE;
    P[22][22] = sq(ftype(windAccuracy));
    P[23][23] = sq(ftype(windAccuracy));
    windStatesAligned = true;
    return true;
}

Optional, but improves dead reckoning accuracy in the first few minutes after position reset. EKF will estimate wind on its own once TAS fusion kicks in.

Why AID_ABSOLUTE instead of staying in AID_NONE

This was the hardest part to get right.

In AID_NONE, the filter runs zero-velocity fusion every cycle. On the ground that’s fine, it constrains tilt drift. But in flight at 20 m/s, the filter tries to reconcile “velocity = 0” with IMU accelerations and starts corrupting pitch/roll estimates. Position goes haywire.

Switching to AID_ABSOLUTE enables wind estimation, proper TAS fusion, and dead reckoning on IMU + airspeed + compass. Without GPS data arriving, the filter dead-reckons from the forced position.

The _has_forced_position flag blocks zero-velocity fusion if the filter ever falls back into AID_NONE logic:

// AP_NavEKF3_PosVelFusion.cpp
if (fuseHgtData && PV_AidingMode == AID_NONE) {
    if (assume_zero_sideslip() && tiltAlignComplete && motorsArmed) {
        fusePosData = false;
        if (_has_forced_position) {
            // Don't fuse zero velocity after force reset.
            // Let IMU + airspeed dead-reckon.
            fuseVelData = false;
        } else {
            // Standard AID_NONE: fuse zero velocity
            velPosObs[0] = 0.0f;
            velPosObs[1] = 0.0f;
            // ...
        }
    }
}

The same flag is checked in AP_NavEKF3_Outputs.cpp to ensure AHRS reports position, wind, and correct status flags even without GPS:

Outputs.cpp:182  if (PV_AidingMode == AID_NONE && !_has_forced_position)
Outputs.cpp:186  if (!inhibitWindStates || _has_forced_position)
Outputs.cpp:223  if (PV_AidingMode != AID_NONE || _has_forced_position)
Outputs.cpp:312  if (getPosD_local(posD) && (PV_AidingMode != AID_NONE || _has_forced_position))

MAVLink handler (ArduPlane side)

In ArduPlane/GCS_Mavlink.cpp, handler for COMMAND_INT:

case 43210: {
    // Custom: Force EKF position reset
    // param1: accuracy (m), 0 = default 5.0
    // x: lat (degE7), y: lon (degE7)
    Location loc;
    loc.lat = packet.x;
    loc.lng = packet.y;
    float accuracy = packet.param1;
    if (accuracy <= 0) accuracy = 5.0f;

    if (AP::ahrs().EKF3.forcePositionReset(loc, accuracy)) {
        return MAV_RESULT_ACCEPTED;
    }
    return MAV_RESULT_FAILED;
}

case 43211: {
    // Custom: Force EKF wind state
    // param1: windN (m/s), param2: windE (m/s), param3: accuracy
    float windN = packet.param1;
    float windE = packet.param2;
    float accuracy = packet.param3;
    if (accuracy <= 0) accuracy = 2.0f;

    if (AP::ahrs().EKF3.forceWindReset(windN, windE, accuracy)) {
        return MAV_RESULT_ACCEPTED;
    }
    return MAV_RESULT_FAILED;
}

GCS side

We use a custom Qt6/MAVSDK GCS (vtol-co-pilot). Operator clicks “Position Correction” button, clicks on map, coordinates go through sendCommandInt(). Wind override takes direction in degrees + speed, GCS converts to NED components.

The interface is simple: any companion computer or GCS can call COMMAND_INT 43210 with lat/lon/accuracy. If you have automated positioning (visual odometry, SLAM, UWB, radar beacons), you can call this automatically instead of manually.

EKF3 boot change

One more small modification: in AP_NavEKF3_core.cpp, the GPS check that prevents EKF initialization without GPS was removed for planes:

// GPS check removed for planes — allows cold start without GPS.
// EKF initialises in AID_NONE; position can be set later via
// forcePositionReset (MAVLink command 43210).

Without this, EKF3 won’t even start its state initialization without a GPS fix.

First call vs repeat calls

This caught me off guard during testing. On the first forcePositionReset(), velocity is still zero from initialization, so we open velocity covariance wide (25 m/s) to let TAS fusion build it up from scratch. On repeat calls in flight, velocity is already correct from dead reckoning, and resetting covariance would destabilize the filter for several minutes until TAS fusion recovers.

Flight test results

Tested on a ~15 kg VTOL QuadPlane, CUAV V5+, custom ArduPlane 4.4.3.

  • Wind: 7-11 m/s
  • Altitudes: 100-1500 m
  • Distances: 1-23 km
  • Position correction: manual by operator using camera payload

Dead reckoning drift between corrections: 50-150 m over 5-7 km. Drift depends mainly on wind stability. Steady wind gives much better results than gusty conditions, which makes sense since airspeed + compass dead reckoning assumes relatively stable wind.

Auto missions work after forcePositionReset(). Guided and auto modes become available, waypoint navigation functions. Operator periodically corrects position, aircraft continues the mission.

Modified files summary

AP_NavEKF3_PosVelFusion.cpp: forcePositionReset(), forceWindReset(), zero-velocity fusion blocking

AP_NavEKF3_core.h: _has_forced_position flag, function declarations

AP_NavEKF3_core.cpp: GPS boot check removal, flag initialization

AP_NavEKF3.cpp: wrapper functions iterating over all cores

AP_NavEKF3_Outputs.cpp: position/wind/status reporting with _has_forced_position

AP_NavEKF3.h: public API declarations

ArduPlane/GCS_Mavlink.cpp: COMMAND_INT handler for 43210/43211

Code

Total modification is roughly 120 lines in the EKF3 core plus the MAVLink handler. The firmware is based on ArduPlane 4.4.3.

Has anyone else tackled GPS-denied flight on fixed-wing ArduPilot? Curious how others have approached it.

9 Likes

Hello,

That is an awesome way to improve ArduPilot and share how you did it ! Even if the firmware you used start beeing old, I really hope we can integrate your change into the current EKF state !

Great job here.

1 Like

Hi, I tried porting all the logic to the 4.8 dev version, and also added GPS recovery. SITL works correctly without any issues, but I can’t test it on a live board yet, only in a few weeks. If you have a chance, you can check out the commit to see how it works. I’m new to pull requests to other repositories, so if you have a chance, could you at least take a look at the code implementation for now?

AP_NavEKF3: add GPS-free forced position reset for fixed-wing · HubMan17/ardupilot@eb6ec51

Three scenarios:

  1. GPS is present – ​​EKF learns the wind normally from the difference between ground velocity (GPS) and airspeed (TAS). Our code doesn’t interfere.
  2. GPS is absent + forceWindReset() is called – the wind is fixed at the transmitted value, and EKF doesn’t learn it (it’s frozen). TAS
    and sideslip fusion use it to determine the speed.
  3. GPS is absent + forceWindReset() isn’t called – the wind is zero. TAS fusion assumes that airspeed ≈ ground speed. In real
    wind, there will be an error, but the controller still works – it has a pilot for control.

When GPS returns – _has_forced_position = false – EKF begins learning the wind normally again.

2 Likes

Hello again, I’m back and would like to try making a pull request to the dev branch. But I’m using a custom Mavlink command for this work. My question is, should I first try to get pull request permissions in the Mavlink repository and then try making a pull request to Ardu? Or can I make a pull request without trying to add new commands to Mavlink?

1 Like

Hi @ArtemZholobov several folks on the Dev Team are also regular contributors and mainters of mavlink. You could consider making a PR for just the custom mavlink command over there to see if its something they want, providing your rationale for it and linking this blog post too.

Meanwhile, on our side, there is no sin to make a draft PR at the same time for ArduPilot for your changes so we can take a look, triage it and make suggestions accordingly so we can start to work together. I’d be surprised if that mavlink command is absolutely essential for the AP side changes, but what you could do is mention the MAVLink PR itself in the AP PR.

Feel free to ask away if you would like more guidance :slight_smile:

1 Like

Hi @ArtemZholobov thank you a lot! I’ve been struggling to get out of zero velocity fusion for some weeks now. I’m reading through your changes, and we’ll test them on some hardware here as well.

I’m not on the Dev Team, but I landed a few PRs both to ArduPilot and MAVLink and getting no GNSS takeoff is a really high priority for me. In my experience, trying to fit new ideas into existing already existing features is appreciated and gets the changes merged faster. So regarding your two custom commands:


case 43210: {
    // Custom: Force EKF position reset
    // param1: accuracy (m), 0 = default 5.0
    // x: lat (degE7), y: lon (degE7)
      <entry value="43003" name="MAV_CMD_EXTERNAL_POSITION_ESTIMATE" hasLocation="true" isDestination="false">
        <description>Provide an external position estimate for use when dead-reckoning. This is meant to be used for occasional position resets that may be provided by a external system such as a remote pilot using landmarks over a video link.</description>
        <param index="1" label="transmission_time" units="s">Timestamp that this message was sent as a time in the transmitters time domain. The sender should wrap this time back to zero based on required timing accuracy for the application and the limitations of a 32 bit float. For example, wrapping at 10 hours would give approximately 1ms accuracy. Recipient must handle time wrap in any timing jitter correction applied to this field. Wrap rollover time should not be at not more than 250 seconds, which would give approximately 10 microsecond accuracy.</param>
        <param index="2" label="processing_time" units="s">The time spent in processing the sensor data that is the basis for this position. The recipient can use this to improve time alignment of the data. Set to zero if not known.</param>
        <param index="3" label="accuracy">estimated one standard deviation accuracy of the measurement. Set to NaN if not known.</param>
        <param index="4">Empty</param>
        <param index="5" label="Latitude">Latitude</param>
        <param index="6" label="Longitude">Longitude</param>
        <param index="7" label="Altitude" units="m">Altitude, not used. Should be sent as NaN. May be supported in a future version of this message.</param>
      </entry>

We already have EXTERNAL_POSITION_ESTIMATE command which does almost the same thing, and already has latitude, longitude and accuracy fields. Parameter 4 is currently unused, maybe we could use it to toggle between current behaviour when set to 0 - just a new estimate of position, or 1 call your forcePositionReset


case 43211: {
    // Custom: Force EKF wind state
    // param1: windN (m/s), param2: windE (m/s), param3: accuracy
      <entry value="43004" name="MAV_CMD_EXTERNAL_WIND_ESTIMATE" hasLocation="false" isDestination="false">
        <description>Set an external estimate of wind direction and speed.
          This might be used to provide an initial wind estimate to the estimator (EKF) in the case where the vehicle is wind dead-reckoning, extending the time when operating without GPS before before position drift builds to an unsafe level. For this use case the command might reasonably be sent every few minutes when operating at altitude, and the value is cleared if the estimator resets itself.
        </description>
        <param index="1" label="Wind speed" units="m/s" minValue="0">Horizontal wind speed.</param>
        <param index="2" label="Wind speed accuracy" units="m/s">Estimated 1 sigma accuracy of wind speed. Set to NaN if unknown.</param>
        <param index="3" label="Direction" units="deg" minValue="0" maxValue="360">Azimuth (relative to true north) from where the wind is blowing.</param>
        <param index="4" label="Direction accuracy" units="deg">Estimated 1 sigma accuracy of wind direction. Set to NaN if unknown.</param>
        <param index="5">Empty</param>
        <param index="6">Empty</param>
        <param index="7">Empty</param>
      </entry>

Similarly here, there is EXTERNAL_WIND_ESTIMATE. Currently it is only used by DCM, but I think that we could put your implementation into EKF, just need to convert from direction and magnitude like DCM does, and add some validation against NaN

I just tested it in SITL with a regular (not quadplane) ArduPlane. It works great!

Takeoff needed a small modification, because it checks GPS directly. Maybe if your new flag was in FLIGHT_OPTIONS instead of EK3_OPTIONS we could check that here.

I got some issues with altitude this way, but when I first sent a correct SET_GPS_GLOBAL_ORIGIN, and then forcePositionReset, everything worked well. I agree that we don’t want to update the altitude in flight, but I wonder why this wasn’t a problem for you?

The override you do to keep AID_ABSOLUTE made EKF3 core deny EXTERNAL_POSITION_ESTIMATE, but adding && !_has_forced_position like you did in some places helped with that.

--- a/ArduPlane/takeoff.cpp
+++ b/ArduPlane/takeoff.cpp
@@ -58,7 +58,8 @@ bool Plane::auto_takeoff_check(void)
     // Check for bad GPS
     if (gps.status() < AP_GPS_FixType::FIX_3D) {
         // no auto takeoff without GPS lock
-        return false;
+        // return false;
+        gcs().send_text(MAV_SEVERITY_WARNING, "No GPS lock, but takeoff anyway");
     }
 
     bool do_takeoff_attitude_check = !(flight_option_enabled(FlightOptions::DISABLE_TOFF_ATTITUDE_CHK));
--- a/libraries/AP_NavEKF3/AP_NavEKF3_PosVelFusion.cpp
+++ b/libraries/AP_NavEKF3/AP_NavEKF3_PosVelFusion.cpp
@@ -190,8 +190,9 @@ void NavEKF3_core::ResetPosition(resetDataSource posResetSource)
 // Returns true if the set was successful
 bool NavEKF3_core::setLatLng(const Location &loc, float posAccuracy, uint32_t timestamp_ms)
 {
-    if ((imuSampleTime_ms - lastGpsPosPassTime_ms) < frontend->deadReckonDeclare_ms ||
-        (PV_AidingMode == AID_NONE)
+    // lastGpsPosPassTime is updated to follow IMU when forced position
+    if (((imuSampleTime_ms - lastGpsPosPassTime_ms) < frontend->deadReckonDeclare_ms && !_has_forced_position)
+        || (PV_AidingMode == AID_NONE)
         || !validOrigin) {
         return false;
     }