Skip to content

refactor(ekf2): separate GNSS heading from position into independent topic#27102

Draft
dakejahl wants to merge 5 commits into
PX4:mainfrom
dakejahl:gnss-heading-separation
Draft

refactor(ekf2): separate GNSS heading from position into independent topic#27102
dakejahl wants to merge 5 commits into
PX4:mainfrom
dakejahl:gnss-heading-separation

Conversation

@dakejahl
Copy link
Copy Markdown
Contributor

Summary

Decouples GNSS heading (from dual-antenna / moving baseline RTK) from GNSS position throughout the pipeline. This resolves the //TODO: move to different message at EKF2.cpp and addresses the architectural issues discussed in #25516.

Problem: Heading was bundled with position in sensor_gps/gnssSample with a single shared timestamp, causing:

  • Timestamp corruption: heading gets the blended-position timestamp instead of its actual measurement time
  • Quality gate coupling: position quality checks (sat count, PDOP, EPH) block heading fusion even when dual-antenna heading is valid
  • Rate coupling: heading arriving faster than position gets rejected by _min_obs_interval_us
  • Starting condition coupling: _gps_intermittent from position blocks heading startup

Changes:

  • New VehicleGnssHeading.msg with independent timestamp
  • VehicleGPSPosition subscribes to sensor_gnss_relative and publishes vehicle_gnss_heading (with fallback from sensor_gps.heading for receivers that don't publish sensor_gnss_relative, e.g. Septentrio)
  • New gnssYawSample struct and separate TimestampedRingBuffer in EKF2
  • Independent buffer pop for heading in gps_control.cpp — no longer gated by _gps_data_ready
  • Removed _gps_intermittent coupling from heading starting conditions
  • Updated gnss_yaw_control.cpp to use gnssYawSample
  • Removed yaw, yaw_acc, yaw_offset fields from gnssSample

Data flow (before → after):

Before:
  sensor_gps → VehicleGPSPosition → vehicle_gps_position → EKF2 (one buffer, one pop)
                (heading bundled)     (one timestamp)

After:
  sensor_gps             → VehicleGPSPosition → vehicle_gps_position → EKF2 (position/vel)
  sensor_gnss_relative   → VehicleGPSPosition → vehicle_gnss_heading → EKF2 (heading, own buffer)

Follow-up work (not in this PR):

  • Septentrio: add sensor_gnss_relative publication (currently uses fallback path)
  • Consolidate heading offset params (GPS_YAW_OFFSET, SEP_YAW_OFFS, EKF2_GPS_YAW_OFF)
  • Phase 2: EKF2 fusion via rel_pos NED vector instead of scalar heading
  • Phase 3: 3-antenna full attitude observability

Relates to #25516, #27088

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

🔎 FLASH Analysis

px4_fmu-v5x [Total VM Diff: 1456 byte (0.07 %)]
    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.1% +1.43Ki  +0.1% +1.43Ki    .text
     +82%    +448   +82%    +448    sensors::VehicleGPSPosition::Run()
    [NEW]    +432  [NEW]    +432    EstimatorInterface::setGnssYawData()
    [NEW]    +344  [NEW]    +344    EKF2::UpdateGnssYawSample()
     +23%    +264   +23%    +264    Ekf::controlGpsFusion()
   -99.7%    +148 -99.7%    +148    [71 Others]
     +12%     +84   +12%     +84    sensors::VehicleGPSPosition::VehicleGPSPosition()
    +0.9%     +48  +0.9%     +48    Ekf::controlMagFusion()
    +0.6%     +36  +0.6%     +36    EKF2::EKF2()
    +2.3%     +32  +2.3%     +32    uORB::Publication<>::publish()
    +1.9%     +28  +1.9%     +28    Ekf::controlBaroHeightFusion()
    +0.1%     +28  +0.1%     +28    uORB::compressed_fields
    +0.0%     +24  +0.0%     +24    [section .text]
    +2.9%     +24  +2.9%     +24    uORB::Publication<>::advertise()
    +8.1%     +20  +8.1%     +20    EstimatorInterface::~EstimatorInterface()
    -0.4%     -16  -0.4%     -16    Ekf::Ekf()
    -1.6%     -16  -1.6%     -16    Ekf::controlAirDataFusion()
    -7.7%     -24  -7.7%     -24    Ekf::stopGnssFusion()
    -6.8%     -28  -6.8%     -28    Ekf::controlGnssYawFusion()
    -0.2%     -32  -0.2%     -32    Ekf::predictCovariance()
   -51.4%    -144 -51.4%    -144    TimestampedRingBuffer<>::reset()
   -37.8%    -236 -37.8%    -236    EKF2::UpdateGpsSample()
  +0.0%    +973  [ = ]       0    .debug_abbrev
  +0.0%     +72  [ = ]       0    .debug_aranges
  +0.1%    +248  [ = ]       0    .debug_frame
  +0.1% +17.9Ki  [ = ]       0    .debug_info
  +0.1% +3.29Ki  [ = ]       0    .debug_line
   -85.7%      -6  [ = ]       0    [Unmapped]
    +0.1% +3.29Ki  [ = ]       0    [section .debug_line]
  +0.1% +2.50Ki  [ = ]       0    .debug_loclists
  +0.1%    +644  [ = ]       0    .debug_rnglists
   -66.7%      -2  [ = ]       0    [Unmapped]
    +0.1%    +646  [ = ]       0    [section .debug_rnglists]
  +0.1% +2.48Ki  [ = ]       0    .debug_str
  -0.8%      -2  [ = ]       0    .shstrtab
  +0.0%    +254  [ = ]       0    .strtab
    [NEW]     +51  [ = ]       0    EKF2::UpdateGnssYawSample()
    -6.2%      -4  [ = ]       0    Ekf::controlGnssYawFusion()
    +6.0%      +3  [ = ]       0    Ekf::updateGnssYaw()
    [NEW]     +77  [ = ]       0    EstimatorInterface::setGnssYawData()
   -48.0%     -61  [ = ]       0    TimestampedRingBuffer<>::reset()
    +0.1%     +25  [ = ]       0    [section .strtab]
     +67%     +16  [ = ]       0    __nxsem_tickwait_veneer
    [NEW]     +27  [ = ]       0    __orb_vehicle_gnss_heading
   -44.4%     -16  [ = ]       0    __sq_addlast_veneer
    +3.2%     +67  [ = ]       0    uORB::Publication<>::advertise()
    +3.9%     +69  [ = ]       0    uORB::Publication<>::publish()
  +0.0%    +160  [ = ]       0    .symtab
   -50.0%     -16  [ = ]       0    EKF2::AdvertiseTopics()
    +100%     +16  [ = ]       0    EKF2::PublishAidSourceStatus()
   -50.0%     -16  [ = ]       0    EKF2::PublishWindEstimate()
   -50.0%     -16  [ = ]       0    EKF2::PublishYawEstimatorStatus()
    [NEW]     +48  [ = ]       0    EKF2::UpdateGnssYawSample()
   -10.0%     -16  [ = ]       0    EKF2::handleSensorFusionCommand()
   -33.3%     -16  [ = ]       0    EKF2::print_usage()
   -50.0%     -16  [ = ]       0    Ekf::controlBetaFusion()
   -25.0%     -16  [ = ]       0    Ekf::controlEvPosFusion()
   -50.0%     -16  [ = ]       0    Ekf::controlGnssPosFusion()
     +33%     +16  [ = ]       0    Ekf::fuseSideslip()
   -25.0%     -16  [ = ]       0    Ekf::updateHorizontalDeadReckoningstatus()
    +100%     +16  [ = ]       0    Ekf::updateVerticalDeadReckoningStatus()
   -50.0%     -16  [ = ]       0    Ekf::updateVerticalPositionAidStatus()
    [NEW]     +48  [ = ]       0    EstimatorInterface::setGnssYawData()
   -50.0%     -48  [ = ]       0    TimestampedRingBuffer<>::reset()
   -98.5%     +48  [ = ]       0    [8 Others]
    +1.3%    +144  [ = ]       0    [section .symtab]
   -25.0%     -16  [ = ]       0    ___ZN4ListIP13MavlinkStreamE8IteratorppEv.isra.0_veneer
     +50%     +16  [ = ]       0    __nxsem_tickwait_uninterruptible_veneer
     +67%     +32  [ = ]       0    __nxsem_tickwait_veneer
 -12.5% -1.42Ki  [ = ]       0    [Unmapped]
  -0.1%      -8  -0.1%      -8    .ramfunc
     +14%      +1   +14%      +1    __nxsem_tickwait_uninterruptible_veneer
   -12.5%      -1 -12.5%      -1    __up_flush_dcache_veneer
    -1.2%      -4  -1.2%      -4    Ekf::measurementUpdate()
   -16.7%      -4 -16.7%      -4    get_orb_meta()
  +0.1% +28.4Ki  +0.1% +1.42Ki    TOTAL

px4_fmu-v6x [Total VM Diff: 1488 byte (0.08 %)]
    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.1% +1.45Ki  +0.1% +1.45Ki    .text
     +82%    +448   +82%    +448    sensors::VehicleGPSPosition::Run()
    [NEW]    +432  [NEW]    +432    EstimatorInterface::setGnssYawData()
    [NEW]    +344  [NEW]    +344    EKF2::UpdateGnssYawSample()
     +23%    +264   +23%    +264    Ekf::controlGpsFusion()
   -99.7%    +152 -99.7%    +152    [74 Others]
     +12%     +84   +12%     +84    sensors::VehicleGPSPosition::VehicleGPSPosition()
    +0.9%     +48  +0.9%     +48    Ekf::controlMagFusion()
    +0.0%     +44  +0.0%     +44    [section .text]
    +0.6%     +36  +0.6%     +36    EKF2::EKF2()
    +2.3%     +32  +2.3%     +32    uORB::Publication<>::publish()
    +1.9%     +28  +1.9%     +28    Ekf::controlBaroHeightFusion()
    +0.1%     +28  +0.1%     +28    uORB::compressed_fields
    +2.8%     +24  +2.8%     +24    uORB::Publication<>::advertise()
    +8.1%     +20  +8.1%     +20    EstimatorInterface::~EstimatorInterface()
    -0.4%     -16  -0.4%     -16    Ekf::Ekf()
    -1.6%     -16  -1.6%     -16    Ekf::controlAirDataFusion()
    -7.7%     -24  -7.7%     -24    Ekf::stopGnssFusion()
    -6.8%     -28  -6.8%     -28    Ekf::controlGnssYawFusion()
    -0.2%     -32  -0.2%     -32    Ekf::predictCovariance()
   -51.4%    -144 -51.4%    -144    TimestampedRingBuffer<>::reset()
   -37.8%    -236 -37.8%    -236    EKF2::UpdateGpsSample()
  +0.1%    +973  [ = ]       0    .debug_abbrev
  +0.0%     +72  [ = ]       0    .debug_aranges
  +0.1%    +248  [ = ]       0    .debug_frame
  +0.1% +17.8Ki  [ = ]       0    .debug_info
  +0.1% +3.30Ki  [ = ]       0    .debug_line
  +0.1% +2.55Ki  [ = ]       0    .debug_loclists
  +0.1%    +645  [ = ]       0    .debug_rnglists
   -33.3%      -1  [ = ]       0    [Unmapped]
    +0.1%    +646  [ = ]       0    [section .debug_rnglists]
  +0.1% +2.48Ki  [ = ]       0    .debug_str
  -0.8%      -2  [ = ]       0    .shstrtab
  +0.0%    +254  [ = ]       0    .strtab
    [NEW]     +51  [ = ]       0    EKF2::UpdateGnssYawSample()
    -6.2%      -4  [ = ]       0    Ekf::controlGnssYawFusion()
    +6.0%      +3  [ = ]       0    Ekf::updateGnssYaw()
    [NEW]     +77  [ = ]       0    EstimatorInterface::setGnssYawData()
   -48.0%     -61  [ = ]       0    TimestampedRingBuffer<>::reset()
    +0.1%     +25  [ = ]       0    [section .strtab]
    [NEW]     +27  [ = ]       0    __orb_vehicle_gnss_heading
    +3.2%     +67  [ = ]       0    uORB::Publication<>::advertise()
    +3.9%     +69  [ = ]       0    uORB::Publication<>::publish()
  +0.0%    +160  [ = ]       0    .symtab
   -50.0%     -16  [ = ]       0    EKF2::AdvertiseTopics()
    +100%     +16  [ = ]       0    EKF2::PublishAidSourceStatus()
   -50.0%     -16  [ = ]       0    EKF2::PublishWindEstimate()
   -50.0%     -16  [ = ]       0    EKF2::PublishYawEstimatorStatus()
    [NEW]     +48  [ = ]       0    EKF2::UpdateGnssYawSample()
   -10.0%     -16  [ = ]       0    EKF2::handleSensorFusionCommand()
   -33.3%     -16  [ = ]       0    EKF2::print_usage()
   -50.0%     -16  [ = ]       0    Ekf::controlBetaFusion()
   -25.0%     -16  [ = ]       0    Ekf::controlEvPosFusion()
   -50.0%     -16  [ = ]       0    Ekf::controlGnssPosFusion()
     +33%     +16  [ = ]       0    Ekf::fuseSideslip()
   -25.0%     -16  [ = ]       0    Ekf::updateHorizontalDeadReckoningstatus()
    +100%     +16  [ = ]       0    Ekf::updateVerticalDeadReckoningStatus()
   -50.0%     -16  [ = ]       0    Ekf::updateVerticalPositionAidStatus()
    [NEW]     +48  [ = ]       0    EstimatorInterface::setGnssYawData()
   -50.0%     -48  [ = ]       0    TimestampedRingBuffer<>::reset()
   -97.9%     +48  [ = ]       0    [3 Others]
    +1.6%    +176  [ = ]       0    [section .symtab]
    [NEW]     +48  [ = ]       0    __orb_vehicle_gnss_heading
   -50.0%     -32  [ = ]       0    fmodf
    -1.9%     -16  [ = ]       0    px4::wq_configurations::nav_and_controllers
 -19.1% -1.45Ki  [ = ]       0    [Unmapped]
  +0.1% +28.4Ki  +0.1% +1.45Ki    TOTAL

Updated: 2026-04-17T07:54:16

@dakejahl dakejahl force-pushed the gnss-heading-separation branch 3 times, most recently from 82d5b9b to 4a82d4f Compare April 16, 2026 10:20
…topic

Decouple GNSS heading (from dual-antenna / moving baseline RTK) from
GNSS position throughout the pipeline. Previously, heading was bundled
with position in sensor_gps/gnssSample with a single shared timestamp,
causing timestamp corruption, quality gate coupling (position problems
blocking heading fusion), and rate coupling.

Changes:
- Add VehicleGnssHeading.msg with independent timestamp
- Extend VehicleGPSPosition to subscribe to sensor_gnss_relative and
  publish vehicle_gnss_heading (with fallback from sensor_gps.heading)
- Add gnssYawSample struct and separate ring buffer in EKF2
- Decouple heading buffer pop from position in gps_control.cpp
- Remove _gps_intermittent coupling from heading starting conditions
- Update gnss_yaw_control.cpp to use gnssYawSample
- Remove yaw fields from gnssSample (resolves the TODO at EKF2.cpp)

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
@dakejahl dakejahl force-pushed the gnss-heading-separation branch from 4a82d4f to ec556f1 Compare April 17, 2026 07:28
…eading timestamp

Mirror the position path: match sensor_gnss_relative.device_id to a
configured receiver slot and subtract SENS_GPS*_DELAY when the driver
didn't distinguish timestamp_sample from timestamp. Without this, the
heading fed into EKF2 used the publish time instead of the measurement
time, negating a key motivation for decoupling heading from position.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
Match surrounding PX4 style for multi-argument log messages.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors EKF2 GNSS yaw handling by decoupling dual-antenna GNSS heading from GNSS position/velocity, introducing a dedicated vehicle_gnss_heading uORB topic and a separate EKF2 yaw sample/buffer path.

Changes:

  • Adds new VehicleGnssHeading uORB message and logs it by default.
  • Updates VehicleGPSPosition to publish vehicle_gnss_heading from sensor_gnss_relative, with a fallback from sensor_gps.heading.
  • Updates EKF2 to ingest GNSS yaw via a new gnssYawSample and independent TimestampedRingBuffer, and updates GNSS yaw control code to use the new sample type.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/modules/sensors/vehicle_gps_position/VehicleGPSPosition.hpp Adds subscriptions/publication for sensor_gnss_relativevehicle_gnss_heading.
src/modules/sensors/vehicle_gps_position/VehicleGPSPosition.cpp Publishes vehicle_gnss_heading (preferred from sensor_gnss_relative, fallback from sensor_gps).
src/modules/logger/logged_topics.cpp Adds vehicle_gnss_heading to default logged topics.
src/modules/ekf2/test/sensor_simulator/gps.h Introduces gnssYawSample storage in the GPS test simulator.
src/modules/ekf2/test/sensor_simulator/gps.cpp Sends yaw samples to EKF via setGnssYawData() and moves yaw setters to the new sample.
src/modules/ekf2/EKF2.hpp Adds subscription and update hook for vehicle_gnss_heading.
src/modules/ekf2/EKF2.cpp Adds UpdateGnssYawSample() and removes yaw fields from the GNSS position sample.
src/modules/ekf2/EKF/estimator_interface.h Adds setGnssYawData() and new yaw buffer/delayed sample members.
src/modules/ekf2/EKF/estimator_interface.cpp Implements independent GNSS yaw buffering with rate limiting.
src/modules/ekf2/EKF/ekf.h Updates GNSS yaw control/update APIs to use gnssYawSample.
src/modules/ekf2/EKF/common.h Removes yaw from gnssSample, introduces gnssYawSample.
src/modules/ekf2/EKF/aid_sources/gnss/gps_control.cpp Pops/fuses GNSS yaw independently of _gps_data_ready.
src/modules/ekf2/EKF/aid_sources/gnss/gnss_yaw_control.cpp Updates yaw fusion logic to take gnssYawSample and adjusts starting conditions.
msg/VehicleGnssHeading.msg New uORB message for validated GNSS heading with independent timestamp.
msg/CMakeLists.txt Registers VehicleGnssHeading.msg for generation.

Comment on lines +229 to +231
_last_gnss_relative_timestamp[i] = gnss_rel.timestamp;

if (gnss_rel.heading_valid && PX4_ISFINITE(gnss_rel.heading)) {
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_last_gnss_relative_timestamp[i] is updated on every sensor_gnss_relative update, even when heading_valid is false. This makes the fallback path treat the topic as active for 3s and can suppress publishing any vehicle_gnss_heading even if a valid sensor_gps.heading exists. Consider only updating the "active" timestamp when a valid heading is present, or track a separate last_valid_heading_timestamp for the fallback gating logic.

Suggested change
_last_gnss_relative_timestamp[i] = gnss_rel.timestamp;
if (gnss_rel.heading_valid && PX4_ISFINITE(gnss_rel.heading)) {
if (gnss_rel.heading_valid && PX4_ISFINITE(gnss_rel.heading)) {
_last_gnss_relative_timestamp[i] = gnss_rel.timestamp;

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +296
if (!any_gnss_relative_active) {
const sensor_gps_s &gps_out = _gps_blending.getOutputGpsData();

if (PX4_ISFINITE(gps_out.heading)) {
vehicle_gnss_heading_s heading_out{};
heading_out.timestamp_sample = (gps_out.timestamp_sample > 0) ? gps_out.timestamp_sample : gps_out.timestamp;
heading_out.device_id = gps_out.device_id;
heading_out.heading = gps_out.heading;
heading_out.heading_accuracy = gps_out.heading_accuracy;
heading_out.heading_offset = gps_out.heading_offset;
heading_out.timestamp = hrt_absolute_time();
_vehicle_gnss_heading_pub.publish(heading_out);
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback publishes heading from _gps_blending.getOutputGpsData(), but blending weights are based on position/velocity quality and can therefore still drop or switch heading when position checks fail (the coupling this PR is trying to remove). For the fallback, consider selecting heading directly from the raw sensor_gps instance(s) (eg selected primary receiver / highest fix type / first finite heading) rather than the blended output.

Copilot uses AI. Check for mistakes.
Comment on lines 30 to +35
_ekf->setGpsData(_gps_data);

if (PX4_ISFINITE(_gnss_yaw_data.yaw)) {
_gnss_yaw_data.time_us = _gps_data.time_us;
_ekf->setGnssYawData(_gnss_yaw_data);
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_gnss_yaw_data is value-initialized ({}), so yaw starts at 0 (finite) and send() will always call setGnssYawData() even when tests never configured yaw. Previously the default yaw was explicitly set to NAN to indicate "not provided". Initialize _gnss_yaw_data.yaw to NAN (and consider doing the same for yaw_acc if you want it treated as unset) so existing tests don’t unintentionally fuse a zero heading.

Copilot uses AI. Check for mistakes.
Comment thread src/modules/ekf2/EKF2.cpp
Comment on lines +2674 to +2680
#if defined(CONFIG_EKF2_GNSS_YAW)
void EKF2::UpdateGnssYawSample(ekf2_timestamps_s &ekf2_timestamps)
{
vehicle_gnss_heading_s gnss_heading;

if (_vehicle_gnss_heading_sub.update(&gnss_heading)) {

Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateGnssYawSample() takes ekf2_timestamps but doesn't use it, which is likely to trigger -Wunused-parameter warnings in PX4 builds. Either mark it unused (eg (void)ekf2_timestamps;) or add an appropriate relative timestamp field (if desired) to Ekf2Timestamps.msg and populate it here.

Copilot uses AI. Check for mistakes.
Comment on lines 68 to +70
const bool starting_conditions_passing = continuing_conditions_passing
&& _gnss_checks.passed()
&& !is_gnss_yaw_data_intermittent
&& !_gps_intermittent;
&& !is_gnss_yaw_data_intermittent;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GNSS yaw fusion is now popped independently from the position buffer, but starting_conditions_passing still requires _gnss_checks.passed(), which is driven by the position/velocity gnssSample quality gates (nsats/PDOP/EPH/…). This keeps the heading startup coupled to position quality and contradicts the stated decoupling goal. If the intent is truly independent yaw fusion, consider removing this dependency or replacing it with yaw-specific validity checks (eg based on heading_accuracy / a dedicated heading health check).

Copilot uses AI. Check for mistakes.
…nded position

Two review-feedback fixes in the vehicle_gnss_heading publish path:

- Fallback: read heading directly from raw sensor_gps subscriptions (primary
  instance first when SENS_GPS_PRIME is configured) instead of the blended
  output. The blended output's selection and weighting depend on
  position/velocity quality, which reintroduced the exact coupling this
  refactor is trying to remove.

- Only mark a sensor_gnss_relative source "active" for the 3 s fallback gate
  when it actually publishes a valid heading. Previously any update — even
  heading_valid=false — would suppress the sensor_gps.heading fallback,
  potentially leaving the EKF without heading for 3 s when a receiver publishes
  sensor_gnss_relative without a heading solution (e.g., lost RTK fix).

Also extract resolveReceiverDelay() / resolveSampleTimestamp() helpers so the
preferred and fallback heading paths don't duplicate the delay/timestamp logic.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
A default-constructed gnssYawSample had yaw=0 (finite), so
any consumer of an uninitialized sample — notably the EKF
sensor-simulator — would fire yaw fusion with a bogus zero
heading. Using NAN as the default matches the "no measurement"
convention used throughout the EKF and the existing
PX4_ISFINITE(yaw) gates in controlGnssYawFusion.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
Copy link
Copy Markdown
Contributor Author

@dakejahl dakejahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this lands cleanly — separate msg + separate buffer + separate pop is the right shape, and the timestamp-resolution helpers are a good extraction.

Main correctness concern: the fallback path in VehicleGPSPosition can republish stale sensor_gps.heading from a receiver that has stopped updating (see inline on line 280). Rest are DRY / consistency / defensive items. One latent bug this quietly fixes is worth advertising in the PR description (see inline on EKF2.cpp:2693).

+1 on Copilot's two open items (_gnss_checks.passed() contradiction on gnss_yaw_control.cpp:70 and the unused ekf2_timestamps parameter on EKF2.cpp:2680) — won't re-litigate inline.

Needs to be tested against a dual-F9P moving-baseline scenario before merge.


Posted by Claude (Opus 4.7) on behalf of @dakejahl.

const uint8_t i = order[idx];
sensor_gps_s gps_data;

if (!_sensor_gps_sub[i].copy(&gps_data)) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_sensor_gps_sub[i].copy() returns the last cached value regardless of freshness, so this path can republish a stale heading after the chosen receiver has stopped updating. Concretely: receiver 0 is sens_gps_prime, stops publishing mid-flight; receiver 1 continues updating, making any_gps_updated true; the loop picks receiver 0 first and re-emits its old heading every tick. Gate on hrt_elapsed_time(&gps_data.timestamp) < N_ms before publishing, or iterate only over instances that were .updated() this cycle.

perf_end(_cycle_perf);
}

hrt_abstime VehicleGPSPosition::resolveReceiverDelay(uint32_t device_id, uint8_t instance_index) const
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveReceiverDelay and resolveSampleTimestamp reimplement the inline slot-matching + delay-subtraction block in Run() (the for slot loop at ~147–164, plus the timestamp_sample fix at ~166–172). Refactor the existing position path to call these so there's one source of truth — today a tweak to either the delay-match rules or the timestamp-resolution rules has to be made in two places.

}

const hrt_abstime delay_us = resolveReceiverDelay(gps_data.device_id, i);
const uint64_t timestamp_sample = resolveSampleTimestamp(gps_data.timestamp_sample,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Position's timestamp_sample gets run through _pps_time_sync.correct_gps_timestamp() on line 209. The heading path here uses only the per-receiver delay. For PPS-synced receivers, vehicle_gps_position and vehicle_gnss_heading will disagree by up to ~100 ms on the same physical sample, undermining the "independent, correct timestamps" story. Plumb PPS correction here too, or comment on why heading isn't PPS-aligned.

const int32_t sens_gps_prime = _param_sens_gps_prime.get();
uint8_t order[GPS_MAX_RECEIVERS] = {0, 1};

if (math::isInRange(sens_gps_prime, 0, static_cast<int32_t>(GPS_MAX_RECEIVERS - 1))) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sens_gps_prime is valid in three ranges: -1 (auto), [0,1] (instance), [2,127] (UAVCAN node ID). This only handles [0,1]. For UAVCAN the primary resolution happens inside _gps_blending (see the setPrimaryInstance(i) call in Run() when the UAVCAN address matches). Reuse _gps_blending.getSelectedGps() here for consistency, or fall back to it when sens_gps_prime > 1.


// Publish vehicle_gnss_heading from sensor_gnss_relative (preferred) or sensor_gps heading (fallback)
{
bool heading_published = false;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heading_published is redundant with any_gnss_relative_active below. Any iteration that sets it to true also sets _last_gnss_relative_timestamp[i] = gnss_rel.timestamp on line 233, which makes any_gnss_relative_active true. Drop the flag and the if (!heading_published && any_gps_updated) prefix and let the any_gnss_relative_active gate do the whole job.

Comment thread src/modules/ekf2/EKF2.cpp
}

if (!PX4_ISFINITE(yaw_offset)) {
yaw_offset = 0.f;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This yaw_offset = 0.f fallback silently fixes a latent bug in the old path: with the u-blox GPS driver (which sets sensor_gps.heading_offset = NAN) and EKF2_GPS_YAW_OFF = 0, the old code propagated NAN into gnssSample.yaw_offset and then into fuseGnssYaw / compute_gnss_yaw_pred_innov_var_and_h, poisoning the innovation variance. Worth calling out in the PR description — it's more than a refactor for that config.

sample_new.time_us = time_us;

_gnss_yaw_buffer->push(sample_new);
_time_last_gnss_yaw_buffer_push = _time_latest_us;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_time_last_gnss_yaw_buffer_push is updated unconditionally, including for samples with NAN yaw. Today the only publishers filter NAN upstream, so this is latent — but the whole point of the new buffer is that anything producing a gnssYawSample can push into it. Gate the timestamp update on PX4_ISFINITE(gnss_yaw_sample.yaw) so the isNewestSampleRecentstopGnssYawFusion path in gps_control.cpp still fires if a future publisher pushes NANs.


if (PX4_ISFINITE(_gnss_yaw_data.yaw)) {
_gnss_yaw_data.time_us = _gps_data.time_us;
_ekf->setGnssYawData(_gnss_yaw_data);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test sensor still always pairs heading with a GPS position sample. The fusion decoupling is the whole point of the PR; worth a dedicated test that exercises _gps_data_ready == false / yaw-only and asserts fuseGnssYaw continues. Otherwise the behavior change is un-gated by CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants