From 0f049493ebd3623615f8a17db30fb4827278c0bb Mon Sep 17 00:00:00 2001 From: joebre Date: Tue, 15 Sep 2026 10:58:17 +0200 Subject: [PATCH 1/3] Add a per-signal broadcast health flag to MonitorPvt's tracked satellites TrackedSatelliteInfo.used only says whether a tracked satellite/signal contributed to the fix, not why it didn't -- below PVT.elevation_mask, RAIM FDE, and broadcast-unhealthy are all indistinguishable to a monitor client. Add a `healthy` field (proto field 8, default true) alongside it. GPS health comes from the decoded ephemeris's SV_health, falling back to the almanac (broadcast by every satellite) when no ephemeris has been decoded yet. Galileo health is resolved per-signal via the existing get_galileo_signal_health(), with the same almanac fallback when no ephemeris is available for that signal's service. Other systems default to healthy=true. --- docs/protobuf/monitor_pvt.proto | 1 + src/algorithms/PVT/libs/monitor_pvt.h | 4 ++ src/algorithms/PVT/libs/rtklib_solver.cc | 39 ++++++++++++++++++++ src/algorithms/PVT/libs/serdes_monitor_pvt.h | 2 + 4 files changed, 46 insertions(+) diff --git a/docs/protobuf/monitor_pvt.proto b/docs/protobuf/monitor_pvt.proto index 4c194fe3e..3e93ba3e6 100644 --- a/docs/protobuf/monitor_pvt.proto +++ b/docs/protobuf/monitor_pvt.proto @@ -69,6 +69,7 @@ message MonitorPvt { double elevation_deg = 5; bool combined = 6; bool used = 7; + bool healthy = 8; } repeated TrackedSatellite tracked_satellites = 37; } diff --git a/src/algorithms/PVT/libs/monitor_pvt.h b/src/algorithms/PVT/libs/monitor_pvt.h index b376cc30c..e6346530f 100644 --- a/src/algorithms/PVT/libs/monitor_pvt.h +++ b/src/algorithms/PVT/libs/monitor_pvt.h @@ -54,6 +54,9 @@ public: // false when tracked (az/el valid) but excluded from the solve, e.g. // below PVT.elevation_mask or by RAIM FDE. bool used{true}; + // Broadcast health status, independent of `used` (which alone can't + // tell a monitor client *why* a satellite was excluded). + bool healthy{true}; template void serialize(Archive& ar, const unsigned int version) @@ -68,6 +71,7 @@ public: ar& BOOST_SERIALIZATION_NVP(elevation_deg); ar& BOOST_SERIALIZATION_NVP(combined); ar& BOOST_SERIALIZATION_NVP(used); + ar& BOOST_SERIALIZATION_NVP(healthy); } }; diff --git a/src/algorithms/PVT/libs/rtklib_solver.cc b/src/algorithms/PVT/libs/rtklib_solver.cc index fe2e31199..96d7f63a7 100644 --- a/src/algorithms/PVT/libs/rtklib_solver.cc +++ b/src/algorithms/PVT/libs/rtklib_solver.cc @@ -3014,6 +3014,28 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ break; } + // GPS health is per-satellite; falls back to the almanac + // (broadcast by every satellite) for one with no ephemeris + // decoded yet. Galileo health is per-signal, resolved below. + // Other systems default to healthy=true. + bool healthy = true; + if (sys_char == 'G') + { + const auto eph_it = gps_ephemeris_map.find(prn); + if (eph_it != gps_ephemeris_map.cend()) + { + healthy = (eph_it->second.SV_health == 0); + } + else + { + const auto alm_it = gps_almanac_map.find(prn); + if (alm_it != gps_almanac_map.cend()) + { + healthy = (alm_it->second.SV_health == 0); + } + } + } + std::vector contributing_signals; for (const auto &observable_pair : gnss_observables_map) { @@ -3041,6 +3063,23 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ info.elevation_deg = pvt_ssat[sat_idx].azel[1] * R2D; info.combined = combined; info.used = used; + info.healthy = healthy; + if (sys_char == 'E') + { + bool galileo_healthy = false; + const auto observation_tow = static_cast(synchro->interp_TOW_ms / 1000.0); + if (get_galileo_signal_health(static_cast(prn), info.signal, observation_tow, galileo_healthy)) + { + info.healthy = galileo_healthy; + } + else + { + // No ephemeris decoded for this signal's service yet -- fall + // back to the almanac, broadcast by every satellite. + const auto alm_it = galileo_almanac_map.find(prn); + info.healthy = (alm_it == galileo_almanac_map.cend()) || (alm_it->second.E1B_HS == 0); + } + } d_monitor_pvt.tracked_satellites.push_back(info); } } diff --git a/src/algorithms/PVT/libs/serdes_monitor_pvt.h b/src/algorithms/PVT/libs/serdes_monitor_pvt.h index dcfdaa518..5f902d0b6 100644 --- a/src/algorithms/PVT/libs/serdes_monitor_pvt.h +++ b/src/algorithms/PVT/libs/serdes_monitor_pvt.h @@ -130,6 +130,7 @@ public: pb_sat->set_elevation_deg(sat.elevation_deg); pb_sat->set_combined(sat.combined); pb_sat->set_used(sat.used); + pb_sat->set_healthy(sat.healthy); } if (!monitor_.SerializeToString(&data)) @@ -190,6 +191,7 @@ public: sat.elevation_deg = pb_sat.elevation_deg(); sat.combined = pb_sat.combined(); sat.used = pb_sat.used(); + sat.healthy = pb_sat.healthy(); monitor.tracked_satellites.push_back(sat); } From e5d7604c9f59b72b895ddf130377ab269d2c2388 Mon Sep 17 00:00:00 2001 From: joebre Date: Tue, 15 Sep 2026 11:50:16 +0200 Subject: [PATCH 2/3] Re-trigger CI (previous run's only failure was the unrelated flaky RinexPrinterTest, not this change) Signed-off-by: joebre From 199e943b8edf3e5ecaadbb7ac86498343e4047b1 Mon Sep 17 00:00:00 2001 From: Carles Fernandez Date: Wed, 16 Sep 2026 11:24:37 +0200 Subject: [PATCH 3/3] Extend the healthy field to more signals --- docs/CHANGELOG.md | 3 +- docs/protobuf/monitor_pvt.proto | 3 +- src/algorithms/PVT/libs/rtklib_solver.cc | 159 ++++++++++++++---- src/algorithms/PVT/libs/rtklib_solver.h | 11 ++ .../libs/rtklib/rtklib_conversions.cc | 18 +- .../libs/rtklib/rtklib_conversions.h | 9 + 6 files changed, 158 insertions(+), 45 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5fb52cf05..15f6850ee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -371,7 +371,8 @@ All notable changes to GNSS-SDR will be documented in this file. ionosphere-free combination), and a `used` flag telling whether it contributed to the reported fix. Satellites that were tracked but left out of the solution (below `PVT.elevation_mask`, or excluded by RAIM) are listed with - `used = false`. Contributed by @joebre. + `used = false`. Unhealthy satellites are listed with `healthy = false`. + Contributed by @joebre. ### Improvements in Maintainability: diff --git a/docs/protobuf/monitor_pvt.proto b/docs/protobuf/monitor_pvt.proto index 3e93ba3e6..2875b38db 100644 --- a/docs/protobuf/monitor_pvt.proto +++ b/docs/protobuf/monitor_pvt.proto @@ -60,7 +60,8 @@ message MonitorPvt { // appears as two entries, both with combined = true. A satellite can have // azimuth_deg/elevation_deg populated with used = false when it was // tracked and had az/el computed but excluded from the fix itself (e.g. - // below PVT.elevation_mask, or by RAIM FDE). + // below PVT.elevation_mask, or by RAIM FDE). The healthy flag carries the + // broadcast health of the signal, independently of used. message TrackedSatellite { uint32 prn = 1; string system = 2; // "G" GPS, "E" Galileo, "R" GLONASS, "C" BeiDou, "S" SBAS, "J" QZSS diff --git a/src/algorithms/PVT/libs/rtklib_solver.cc b/src/algorithms/PVT/libs/rtklib_solver.cc index 4f9327e63..4dafbb894 100644 --- a/src/algorithms/PVT/libs/rtklib_solver.cc +++ b/src/algorithms/PVT/libs/rtklib_solver.cc @@ -1419,6 +1419,121 @@ bool Rtklib_Solver::get_galileo_signal_health(uint32_t prn, const std::string &s } +bool Rtklib_Solver::get_broadcast_signal_health(char system, uint32_t prn, const std::string &signal, + uint32_t observation_tow, bool &healthy) const +{ + const auto prn_key = static_cast(prn); + switch (system) + { + case 'G': + case 'J': + { + // L2C and L5 health comes from the CNAV message that carries them: + // bits 52-54 of message type 10 are the L1, L2 and L5 signal health + // (0 = OK, IS-GPS-200 30.3.3.1.1.2). + if (signal == "2S" || signal == "L5" || signal == "J5") + { + const auto cnav_it = gps_cnav_ephemeris_map.find(prn_key); + if (cnav_it != gps_cnav_ephemeris_map.cend()) + { + const int32_t signal_bit = (signal == "2S") ? 0x2 : 0x1; + healthy = ((cnav_it->second.signal_health & signal_bit) == 0); + return true; + } + } + // L1 C/A: per-satellite SV health from LNAV subframe 1, falling back + // to the almanac (broadcast by every satellite) when no ephemeris has + // been decoded for this satellite yet. + const auto eph_it = gps_ephemeris_map.find(prn_key); + if (eph_it != gps_ephemeris_map.cend()) + { + healthy = (eph_it->second.SV_health == 0); + return true; + } + const auto alm_it = gps_almanac_map.find(prn_key); + if (alm_it != gps_almanac_map.cend()) + { + healthy = (alm_it->second.SV_health == 0); + return true; + } + return false; + } + case 'E': + { + if (get_galileo_signal_health(prn, signal, observation_tow, healthy)) + { + return true; + } + // No ephemeris decoded for this signal's service yet -- fall + // back to the almanac, broadcast by every satellite. + const auto alm_it = galileo_almanac_map.find(prn_key); + if (alm_it != galileo_almanac_map.cend()) + { + healthy = (alm_it->second.E1B_HS == 0); + return true; + } + return false; + } + case 'R': + { + // Same predicate as the ephemeris handed to RTKLIB (see + // eph_to_rtklib()), so a GLONASS satellite excluded from the solve + // for health reasons is reported with used = false, healthy = false. + const auto eph_it = glonass_gnav_ephemeris_map.find(prn_key); + if (eph_it != glonass_gnav_ephemeris_map.cend()) + { + healthy = glonass_gnav_is_healthy(eph_it->second, d_conf.glonass_strict_health); + return true; + } + return false; + } + case 'C': + { + if (signal == "1D") + { + // B1C: B-CNAV1 subframe 3 health status (HS, 0 = healthy). The + // latest page data is preferred over the ephemeris record, as + // when the ephemeris is handed to RTKLIB. + const auto page_it = beidou_cnav1_page_data_map.find(prn_key); + if (page_it != beidou_cnav1_page_data_map.cend()) + { + healthy = (page_it->second.common.hs == 0); + return true; + } + const auto cnav1_it = beidou_cnav1_ephemeris_map.find(prn_key); + if (cnav1_it != beidou_cnav1_ephemeris_map.cend()) + { + healthy = (cnav1_it->second.hs == 0); + return true; + } + return false; + } + if (signal == "5D") + { + // B2a: B-CNAV2 health status (HS, 0 = healthy) + const auto cnav2_it = beidou_cnav2_ephemeris_map.find(prn_key); + if (cnav2_it != beidou_cnav2_ephemeris_map.cend()) + { + healthy = (cnav2_it->second.hs == 0); + return true; + } + return false; + } + // B1I / B3I: DNAV satellite health (SatH1, 0 = healthy) + const auto dnav_it = beidou_dnav_ephemeris_map.find(prn_key); + if (dnav_it != beidou_dnav_ephemeris_map.cend()) + { + healthy = (dnav_it->second.SV_health == 0); + return true; + } + return false; + } + default: + return false; + } +} + + std::map Rtklib_Solver::get_galileo_ephemeris_map_for_pvt() const { auto result = galileo_ephemeris_store.combined_view(d_galileo_nav_message_type_for_pvt); @@ -3045,28 +3160,6 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ break; } - // GPS health is per-satellite; falls back to the almanac - // (broadcast by every satellite) for one with no ephemeris - // decoded yet. Galileo health is per-signal, resolved below. - // Other systems default to healthy=true. - bool healthy = true; - if (sys_char == 'G') - { - const auto eph_it = gps_ephemeris_map.find(prn); - if (eph_it != gps_ephemeris_map.cend()) - { - healthy = (eph_it->second.SV_health == 0); - } - else - { - const auto alm_it = gps_almanac_map.find(prn); - if (alm_it != gps_almanac_map.cend()) - { - healthy = (alm_it->second.SV_health == 0); - } - } - } - std::vector contributing_signals; for (const auto &observable_pair : gnss_observables_map) { @@ -3094,22 +3187,14 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ info.elevation_deg = pvt_ssat[sat_idx].azel[1] * R2D; info.combined = combined; info.used = used; - info.healthy = healthy; - if (sys_char == 'E') + // Broadcast health of this signal, as reported by the + // navigation message that carries it (true when no health + // information is available). Independent of `used`. + const auto observation_tow = static_cast(synchro->interp_TOW_ms / 1000.0); + bool healthy = true; + if (get_broadcast_signal_health(sys_char, static_cast(prn), info.signal, observation_tow, healthy)) { - bool galileo_healthy = false; - const auto observation_tow = static_cast(synchro->interp_TOW_ms / 1000.0); - if (get_galileo_signal_health(static_cast(prn), info.signal, observation_tow, galileo_healthy)) - { - info.healthy = galileo_healthy; - } - else - { - // No ephemeris decoded for this signal's service yet -- fall - // back to the almanac, broadcast by every satellite. - const auto alm_it = galileo_almanac_map.find(prn); - info.healthy = (alm_it == galileo_almanac_map.cend()) || (alm_it->second.E1B_HS == 0); - } + info.healthy = healthy; } d_monitor_pvt.tracked_satellites.push_back(info); } diff --git a/src/algorithms/PVT/libs/rtklib_solver.h b/src/algorithms/PVT/libs/rtklib_solver.h index 51c2e9acd..74afd1bac 100644 --- a/src/algorithms/PVT/libs/rtklib_solver.h +++ b/src/algorithms/PVT/libs/rtklib_solver.h @@ -161,6 +161,17 @@ public: Galileo_Nav_Message_Type galileo_nav_message_type_for_pvt() const; bool is_galileo_signal_used_in_pvt(const std::string& signal) const; bool get_galileo_signal_health(uint32_t prn, const std::string& signal, uint32_t observation_tow, bool& healthy) const; + /*! + * \brief Broadcast health of one tracked signal, as reported by the navigation + * message that carries it: GPS/QZSS L1 C/A from the LNAV SV health (almanac + * when no ephemeris has been decoded yet), GPS/QZSS L2C and L5 from the CNAV + * per-signal health bits, Galileo per signal (see get_galileo_signal_health(), + * almanac E1B_HS when no ephemeris is available), GLONASS from the GNAV ln/Bn + * flags, BeiDou B1I/B3I from the DNAV SV health and B1C/B2a from the + * B-CNAV1/B-CNAV2 health status. Independent of PVT.use_unhealthy_sats. + * \return true if health information is available for that signal (healthy is then set) + */ + bool get_broadcast_signal_health(char system, uint32_t prn, const std::string& signal, uint32_t observation_tow, bool& healthy) const; std::map get_galileo_ephemeris_map_for_pvt() const; bool select_galileo_ephemeris(uint32_t prn, const std::string& signal, uint32_t observation_tow, Galileo_Ephemeris& ephemeris, bool& from_reduced_ced) const; diff --git a/src/algorithms/libs/rtklib/rtklib_conversions.cc b/src/algorithms/libs/rtklib/rtklib_conversions.cc index 20f424291..1e54577a1 100644 --- a/src/algorithms/libs/rtklib/rtklib_conversions.cc +++ b/src/algorithms/libs/rtklib/rtklib_conversions.cc @@ -402,22 +402,28 @@ obsd_t insert_obs_to_rtklib(obsd_t& rtklib_obs, } +bool glonass_gnav_is_healthy(const Glonass_Gnav_Ephemeris& glonass_gnav_eph, bool glonass_strict_health) +{ + // The ln flag (string 3) always marks a satellite malfunction. The MSB of + // the Bn word is a stricter health indicator that is only applied when + // glonass_strict_health is enabled + const bool bn_msb_unhealthy = glonass_strict_health && ((static_cast(glonass_gnav_eph.d_B_n) & 4) != 0); + return !(bn_msb_unhealthy || glonass_gnav_eph.d_l3rd_n); +} + + geph_t eph_to_rtklib(const Glonass_Gnav_Ephemeris& glonass_gnav_eph, const Glonass_Gnav_Utc_Model& gnav_clock_model, bool glonass_strict_health) { int week; double sec; int adj_week; geph_t rtklib_sat = {0, 0, 0, 0, 0, 0, {0, 0}, {0, 0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, 0.0, 0.0, 0.0}; - - // The ln flag (string 3) always marks a satellite malfunction. The MSB of - // the Bn word is a stricter health indicator that is only applied when - // glonass_strict_health is enabled - const bool bn_msb_unhealthy = glonass_strict_health && ((static_cast(glonass_gnav_eph.d_B_n) & 4) != 0); + const bool healthy = glonass_gnav_is_healthy(glonass_gnav_eph, glonass_strict_health); rtklib_sat.sat = satno(SYS_GLO, glonass_gnav_eph.i_satellite_slot_number); /* satellite number */ rtklib_sat.iode = static_cast(std::lround(glonass_gnav_eph.d_t_b / 900.0)); /* IODE (tb interval index) */ rtklib_sat.frq = glonass_gnav_eph.i_satellite_freq_channel; /* satellite frequency number */ - rtklib_sat.svh = (bn_msb_unhealthy || glonass_gnav_eph.d_l3rd_n) ? 1 : 0; /* satellite health from the ln flag (and optionally the Bn MSB) */ + rtklib_sat.svh = healthy ? 0 : 1; /* satellite health from the ln flag (and optionally the Bn MSB) */ rtklib_sat.sva = static_cast(glonass_gnav_eph.d_F_T); /* satellite accuracy*/ rtklib_sat.age = static_cast(glonass_gnav_eph.d_E_n); /* satellite age*/ rtklib_sat.pos[0] = glonass_gnav_eph.d_Xn * 1000; /* satellite position (ecef) (m) */ diff --git a/src/algorithms/libs/rtklib/rtklib_conversions.h b/src/algorithms/libs/rtklib/rtklib_conversions.h index 449b6752e..4955c086f 100644 --- a/src/algorithms/libs/rtklib/rtklib_conversions.h +++ b/src/algorithms/libs/rtklib/rtklib_conversions.h @@ -127,6 +127,15 @@ alm_t alm_to_rtklib(const Gps_Almanac& gps_alm, int ref_week = 0); alm_t alm_to_rtklib(const Galileo_Almanac& gal_alm, int ref_week = 0); alm_t alm_to_rtklib(const Beidou_Dnav_Almanac& bei_alm); +/*! + * \brief Broadcast health of a GLONASS satellite according to its GNAV ephemeris + * \param glonass_gnav_eph GLONASS GNAV Ephemeris structure + * \param glonass_strict_health when true (default), the MSB of the Bn word also marks the + * satellite as unhealthy; when false, only the ln flag is used + * \return true if the satellite does not report a malfunction + */ +bool glonass_gnav_is_healthy(const Glonass_Gnav_Ephemeris& glonass_gnav_eph, bool glonass_strict_health = true); + /*! * \brief Transforms a Glonass_Gnav_Ephemeris to its RTKLIB counterpart * \param glonass_gnav_eph GLONASS GNAV Ephemeris structure