From d9cdc16daa2f4336d17c85e65e36e023bf105536 Mon Sep 17 00:00:00 2001 From: Carles Fernandez Date: Tue, 18 Aug 2026 21:31:57 +0200 Subject: [PATCH] Address the findings of a second deep review of the RTK and NTRIP code --- cmake/Modules/GnsssdrCrypto.cmake | 42 ++- src/algorithms/PVT/adapters/rtklib_pvt.cc | 38 ++- .../PVT/gnuradio_blocks/rtklib_pvt_gs.cc | 93 ++++-- .../PVT/gnuradio_blocks/rtklib_pvt_gs.h | 9 +- src/algorithms/PVT/libs/CMakeLists.txt | 2 + src/algorithms/PVT/libs/ntrip_rtcm_client.cc | 223 ++++++------- src/algorithms/PVT/libs/ntrip_rtcm_client.h | 26 +- src/algorithms/PVT/libs/ntrip_rtk_signals.h | 128 ++++++++ src/algorithms/PVT/libs/pvt_conf.h | 11 +- src/algorithms/PVT/libs/rtklib_solver.cc | 172 +++++++--- src/algorithms/PVT/libs/rtklib_solver.h | 8 + src/algorithms/PVT/libs/secure_string.h | 112 +++++++ src/algorithms/libs/rtklib/CMakeLists.txt | 12 +- src/algorithms/libs/rtklib/rtklib.h | 31 +- src/algorithms/libs/rtklib/rtklib_pntpos.cc | 34 +- src/algorithms/libs/rtklib/rtklib_ppp.cc | 2 +- src/algorithms/libs/rtklib/rtklib_rtkcmn.cc | 42 +++ src/algorithms/libs/rtklib/rtklib_rtkcmn.h | 2 + src/algorithms/libs/rtklib/rtklib_rtkpos.cc | 194 +++++------- src/algorithms/libs/rtklib/rtklib_rtkpos.h | 4 +- src/algorithms/libs/rtklib/rtklib_rtksvr.cc | 26 +- src/algorithms/libs/rtklib/rtklib_stream.cc | 146 +++++---- src/algorithms/libs/rtklib/rtklib_stream.h | 5 + src/algorithms/libs/rtklib/rtklib_tls.cc | 58 +++- src/algorithms/libs/rtklib/rtklib_tls.h | 6 + src/core/libs/CMakeLists.txt | 6 +- src/core/libs/supl/CMakeLists.txt | 5 +- tests/CMakeLists.txt | 3 + tests/test_main.cc | 4 + .../pvt/bds_tgd_iono_test.cc | 199 ++++++++++++ .../pvt/gps_tgd_isc_test.cc | 293 ++++++++++++++++++ .../pvt/ntrip_rtcm_client_test.cc | 3 +- .../pvt/rtklib_detslp_dop_test.cc | 126 ++++++++ .../pvt/rtklib_fixed_base_test.cc | 2 +- .../rtklib_pvt_ntrip_configuration_test.cc | 36 +++ .../pvt/rtklib_udpos_test.cc | 237 ++++++++++++++ 36 files changed, 1895 insertions(+), 445 deletions(-) create mode 100644 src/algorithms/PVT/libs/ntrip_rtk_signals.h create mode 100644 src/algorithms/PVT/libs/secure_string.h create mode 100644 tests/unit-tests/signal-processing-blocks/pvt/bds_tgd_iono_test.cc create mode 100644 tests/unit-tests/signal-processing-blocks/pvt/gps_tgd_isc_test.cc create mode 100644 tests/unit-tests/signal-processing-blocks/pvt/rtklib_detslp_dop_test.cc create mode 100644 tests/unit-tests/signal-processing-blocks/pvt/rtklib_udpos_test.cc diff --git a/cmake/Modules/GnsssdrCrypto.cmake b/cmake/Modules/GnsssdrCrypto.cmake index 541831f1a..d8340f7a5 100644 --- a/cmake/Modules/GnsssdrCrypto.cmake +++ b/cmake/Modules/GnsssdrCrypto.cmake @@ -23,9 +23,13 @@ # GNSSSDR_GNUTLS_INCLUDE_DIR - Path to the GnuTLS include directory # # Provided function: -# link_to_crypto_dependencies() +# link_to_crypto_dependencies( []) # Links against the selected crypto backend and sets the -# appropriate compile definitions: +# appropriate compile definitions. (PUBLIC, PRIVATE or INTERFACE, +# default PUBLIC) controls whether the backend libraries, include +# directories and USE_* definitions propagate to the target's consumers; +# use PRIVATE when the crypto backend is an implementation detail that no +# public header of exposes: # USE_OPENSSL_3=1 (OpenSSL >= 3.0.0) # USE_OPENSSL_111=1 (OpenSSL >= 1.1.1, < 3.0.0) # USE_GNUTLS_FALLBACK=1 (GnuTLS backend) @@ -326,10 +330,20 @@ function(link_to_crypto_dependencies target) ) endif() + set(scope PUBLIC) + if(ARGC GREATER 1) + set(scope ${ARGV1}) + if(NOT scope MATCHES "^(PUBLIC|PRIVATE|INTERFACE)$") + message(FATAL_ERROR + "link_to_crypto_dependencies: invalid scope ${scope} (expected PUBLIC, PRIVATE or INTERFACE)" + ) + endif() + endif() + if(GNSSSDR_OPENSSL_FOUND) if(TARGET OpenSSL::SSL) target_link_libraries(${target} - PUBLIC + ${scope} OpenSSL::SSL ) @@ -354,19 +368,19 @@ function(link_to_crypto_dependencies target) if(_gnsssdr_openssl_libraries) list(REMOVE_DUPLICATES _gnsssdr_openssl_libraries) target_link_libraries(${target} - PUBLIC + ${scope} ${_gnsssdr_openssl_libraries} ) endif() if(OPENSSL_INCLUDE_DIR) target_include_directories(${target} - PUBLIC + ${scope} ${OPENSSL_INCLUDE_DIR} ) elseif(OPENSSL_INCLUDE_DIRS) target_include_directories(${target} - PUBLIC + ${scope} ${OPENSSL_INCLUDE_DIRS} ) endif() @@ -377,12 +391,12 @@ function(link_to_crypto_dependencies target) if(GNSSSDR_OPENSSL_VERSION_NUMBER) if(NOT "${GNSSSDR_OPENSSL_VERSION_NUMBER}" VERSION_LESS "3.0.0") target_compile_definitions(${target} - PUBLIC + ${scope} USE_OPENSSL_3=1 ) elseif(NOT "${GNSSSDR_OPENSSL_VERSION_NUMBER}" VERSION_LESS "1.1.1") target_compile_definitions(${target} - PUBLIC + ${scope} USE_OPENSSL_111=1 ) endif() @@ -390,25 +404,25 @@ function(link_to_crypto_dependencies target) else() if(TARGET GnuTLS::GnuTLS) target_link_libraries(${target} - PUBLIC + ${scope} GnuTLS::GnuTLS ${GNUTLS_OPENSSL_LIBRARY} ) else() target_link_libraries(${target} - PUBLIC + ${scope} ${GNUTLS_LIBRARIES} ${GNUTLS_OPENSSL_LIBRARY} ) if(GNUTLS_INCLUDE_DIRS) target_include_directories(${target} - PUBLIC + ${scope} ${GNUTLS_INCLUDE_DIRS} ) elseif(GNSSSDR_GNUTLS_INCLUDE_DIR) target_include_directories(${target} - PUBLIC + ${scope} ${GNSSSDR_GNUTLS_INCLUDE_DIR} ) endif() @@ -442,13 +456,13 @@ function(link_to_crypto_dependencies target) if(GNUTLS_DEFINITIONS) target_compile_options(${target} - PUBLIC + ${scope} ${GNUTLS_DEFINITIONS} ) endif() target_compile_definitions(${target} - PUBLIC + ${scope} USE_GNUTLS_FALLBACK=1 ) diff --git a/src/algorithms/PVT/adapters/rtklib_pvt.cc b/src/algorithms/PVT/adapters/rtklib_pvt.cc index e74492ec0..5341e3677 100644 --- a/src/algorithms/PVT/adapters/rtklib_pvt.cc +++ b/src/algorithms/PVT/adapters/rtklib_pvt.cc @@ -27,6 +27,7 @@ #include "gps_ephemeris.h" // for Gps_Ephemeris #include "gps_week_rollover.h" // for gps_ref_week_from_config #include "ntrip_rtcm_client.h" // for make_ntrip_rtcm_client_config, secure_clear_string +#include "ntrip_rtk_signals.h" // for the NTRIP RTK signal table and derived masks #include "pvt_conf.h" // for Pvt_Conf #include "rtklib_rtkpos.h" // for rtkfree, rtkinit #include "signal_enabled_flags.h" // for signal_enabled_flags @@ -68,9 +69,7 @@ public: ~Ntrip_Credential_Guard() { - secure_clear_string(&d_configuration->ntrip_username); - secure_clear_string(&d_configuration->ntrip_password); - secure_clear_string(&d_configuration->ntrip_password_env); + secure_clear_ntrip_credentials(d_configuration); } Ntrip_Credential_Guard(const Ntrip_Credential_Guard&) = delete; @@ -254,11 +253,11 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration, { throw std::invalid_argument(role + ".ntrip_password and .ntrip_password_env are mutually exclusive"); } - if (has_space_or_control(pvt_output_parameters.ntrip_password_env)) + if (has_space_or_control(pvt_output_parameters.ntrip_password_env.value())) { throw std::invalid_argument(role + ".ntrip_password_env must be one environment-variable name"); } - const char* password = std::getenv(pvt_output_parameters.ntrip_password_env.c_str()); + const char* password = std::getenv(pvt_output_parameters.ntrip_password_env.value().c_str()); if (password == nullptr) { throw std::invalid_argument(role + ".ntrip_password_env names an environment variable that is not set"); @@ -357,7 +356,7 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration, const bool ntrip_gal_e5a = signal_enabled_flags.check_any_enabled(GAL_E5a); const bool ntrip_bds_b1c = signal_enabled_flags.check_any_enabled(BDS_B1C); const bool ntrip_only_supported_signals = - (signal_enabled_flags.flags & ~(GPS_1C | GPS_2S | GPS_L5 | GAL_1B | GAL_E5a | BDS_B1C)) == 0; + (signal_enabled_flags.flags & ~ntrip_rtk_supported_signal_mask()) == 0; const bool ntrip_valid_channel_set = ntrip_only_supported_signals && (ntrip_gps_l1 || ntrip_gal_e1 || ntrip_bds_b1c) && @@ -413,10 +412,10 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration, } if (pvt_output_parameters.ntrip_client_enabled) { - // The slot count is fully determined by the channel set: GPS L5 - // and Galileo E5a occupy RTKLIB's third frequency slot, GPS L2C - // the second, and single-band sets run on the first slot alone - const int ntrip_required_bands = (ntrip_gps_l5 || ntrip_gal_e5a) ? 3 : (ntrip_gps_l2 ? 2 : 1); + // The slot count is fully determined by the channel set (see the + // slot column of NTRIP_RTK_SIGNALS): single-band sets run on the + // first slot alone + const int ntrip_required_bands = ntrip_rtk_required_bands(signal_enabled_flags); if (number_of_frequencies != ntrip_required_bands) { LOG(INFO) << "NTRIP RTK: setting the number of frequency slots to " << ntrip_required_bands; @@ -592,9 +591,7 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration, } if (pvt_output_parameters.ntrip_client_enabled) { - const int expected_navigation_system = - (ntrip_gps_l1 ? SYS_GPS : 0) | (ntrip_gal_e1 ? SYS_GAL : 0) | - (ntrip_bds_b1c ? SYS_BDS : 0); + const int expected_navigation_system = ntrip_rtk_navigation_systems(signal_enabled_flags); if (navigation_system != expected_navigation_system) { throw std::invalid_argument(role + ".navigation_system must select exactly the constellations of the enabled RTK channels"); @@ -798,6 +795,21 @@ Rtklib_Pvt::Rtklib_Pvt(const ConfigurationInterface* configuration, ar_max_position_variance /* max position variance to attempt AR (m^2) */ }; + if (integer_ambiguity_resolution_gps > 0 && + (ar_filter || min_fix_sats > 0 || min_hold_sats > 0 || min_drop_sats > 0 || ar_max_position_variance > 0.0)) + { + // These gates default ON (demo5 behavior), unlike the RTKLIB + // PRCOPT_DEFAULT zeros: say so where the user can see why AR may + // silently be skipped (e.g. float variance above the gate) + LOG(INFO) << "RTK AR management gates active: ar_filter=" << (ar_filter ? "true" : "false") + << ", min_fix_sats=" << min_fix_sats + << ", min_hold_sats=" << min_hold_sats + << ", min_drop_sats=" << min_drop_sats + << ", ar_max_position_variance=" << ar_max_position_variance + << " m^2 (AR is skipped while the float position variance exceeds it;" + << " set these options to 0/false for the classic RTKLIB behavior)"; + } + // Outputs const bool default_output_enabled = configuration->property(role + ".output_enabled", true); pvt_output_parameters.output_enabled = default_output_enabled; diff --git a/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.cc b/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.cc index a0dedf56b..8a7c0123f 100644 --- a/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.cc +++ b/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.cc @@ -266,6 +266,13 @@ rtklib_pvt_gs::rtklib_pvt_gs(uint32_t nchannels, d_use_unhealthy_sats(conf_.use_unhealthy_sats), d_osnma_strict(conf_.osnma_strict) { + if (conf_.ntrip_client_enabled && !conf_.enable_rx_clock_correction) + { + // do not override an explicit user setting silently: the timetag + // processing path only runs when the clock correction is disabled + std::cout << "Warning: PVT.enable_rx_clock_correction=false is overridden because the NTRIP client requires the receiver clock correction\n"; + LOG(WARNING) << "PVT.enable_rx_clock_correction=false overridden: the NTRIP client requires the receiver clock correction, so source-timetag processing is disabled"; + } // Send feedback message to observables block with the receiver clock offset this->message_port_register_out(pmt::mp("pvt_to_observables")); // Experimental: VLT commands from PVT to tracking channels @@ -2385,6 +2392,14 @@ void rtklib_pvt_gs::report_ntrip_client_status() { return; } + /* the status copy locks the client's mutex and allocates for the message + string; skip it while nothing has changed (this runs once per epoch) */ + const uint64_t state_generation = d_ntrip_client->state_generation(); + if (state_generation == d_last_ntrip_state_generation) + { + return; + } + d_last_ntrip_state_generation = state_generation; const Ntrip_Rtcm_Client_Status status = d_ntrip_client->status(); const int state = static_cast(status.state); if (state == d_last_ntrip_client_state) @@ -2444,7 +2459,10 @@ void rtklib_pvt_gs::report_fixed_base_status() LOG(WARNING) << "NTRIP base position is invalid"; break; case Rtklib_Fixed_Base_Status::INSUFFICIENT_COMMON_SATELLITES: - LOG(WARNING) << "NTRIP RTK has fewer than four common GPS satellites: " + /* the count spans every RTK constellation; the threshold is the + solver's, not this display layer's */ + LOG(WARNING) << "NTRIP RTK has fewer than " << Rtklib_Solver::NTRIP_MIN_COMMON_SATELLITES + << " common satellites: " << d_user_pvt_solver->get_fixed_base_common_satellites(); break; case Rtklib_Fixed_Base_Status::NOT_REQUESTED: @@ -2454,22 +2472,11 @@ void rtklib_pvt_gs::report_fixed_base_status() } -int rtklib_pvt_gs::displayed_solution_status() const -{ - const int status = d_user_pvt_solver->pvt_sol.stat; - /* RTKLIB stamps SOLQ_PPP whenever the PPP filter runs, even on broadcast - orbits and clocks; only claim PPP when precise products are loaded */ - if (status == SOLQ_PPP && !d_user_pvt_solver->has_precise_ephemeris()) - { - return SOLQ_SINGLE; - } - return status; -} - - void rtklib_pvt_gs::report_solution_status() { - const int status = displayed_solution_status(); + /* the solver already demotes broadcast-products PPP to SOLQ_SINGLE, so + pvt_sol.stat is the effective status for every output */ + const int status = d_user_pvt_solver->pvt_sol.stat; if (status == d_last_solution_status) { return; @@ -2532,6 +2539,39 @@ void rtklib_pvt_gs::report_solution_status() } +void rtklib_pvt_gs::report_solution_outage() +{ + /* a failed solve from a labeled state must be announced and must clear + d_last_solution_status: otherwise an outage that recovers into the same + status (e.g. FIXED -> outage -> FIXED) would print nothing at all */ + const int previous_status = d_last_solution_status; + if (previous_status == SOLQ_NONE) + { + return; + } + d_last_solution_status = SOLQ_NONE; + if (solution_status_tag(previous_status).empty()) + { + return; /* plain single-point operation keeps the classic output */ + } + std::ostringstream report; + if (previous_status == SOLQ_SBAS) + { + report << "SBAS corrections lost: no PVT solution"; + } + else if (previous_status == SOLQ_PPP) + { + report << "PPP lost: no PVT solution"; + } + else + { + report << "RTK lost: no PVT solution"; + } + std::cout << TEXT_BOLD_YELLOW << report.str() << TEXT_RESET << std::endl; + LOG(INFO) << "Solution status change: " << report.str(); +} + + int rtklib_pvt_gs::work(int noutput_items, gr_vector_const_void_star& input_items, gr_vector_void_star& output_items __attribute__((unused))) { @@ -2908,6 +2948,7 @@ int rtklib_pvt_gs::work(int noutput_items, gr_vector_const_void_star& input_item } else { + report_solution_outage(); // sanity check: If the PVT solver is getting 100 consecutive errors, send a reset command to observables block d_pvt_errors_counter++; if (d_pvt_errors_counter >= 100) @@ -2926,12 +2967,20 @@ int rtklib_pvt_gs::work(int noutput_items, gr_vector_const_void_star& input_item // epoch twice to the solver and duplicate the dump record if (flag_compute_pvt_output == true && d_enable_rx_clock_correction == true) { - Ntrip_Rtcm_Snapshot fixed_base_snapshot; const Ntrip_Rtcm_Snapshot* fixed_base = nullptr; if (d_ntrip_client) { - fixed_base_snapshot = d_ntrip_client->latest_snapshot(d_internal_pvt_solver->pvt_sol.time); - fixed_base = &fixed_base_snapshot; + /* deep-copy the snapshot only when the client's data + actually changed; base epochs arrive at ~1 Hz while + this path runs at the output rate */ + const uint64_t snapshot_generation = d_ntrip_client->snapshot_generation(); + if (!d_fixed_base_snapshot || snapshot_generation != d_ntrip_snapshot_generation) + { + d_fixed_base_snapshot.reset(new Ntrip_Rtcm_Snapshot( + d_ntrip_client->latest_snapshot(d_internal_pvt_solver->pvt_sol.time))); + d_ntrip_snapshot_generation = snapshot_generation; + } + fixed_base = d_fixed_base_snapshot.get(); } flag_pvt_valid = d_user_pvt_solver->get_PVT( d_gnss_observables_map, @@ -2940,6 +2989,10 @@ int rtklib_pvt_gs::work(int noutput_items, gr_vector_const_void_star& input_item true, fixed_base); report_fixed_base_status(); + if (!flag_pvt_valid) + { + report_solution_outage(); + } } if (flag_pvt_valid == true) @@ -3056,7 +3109,7 @@ int rtklib_pvt_gs::work(int noutput_items, gr_vector_const_void_star& input_item << " [deg], Long = " << d_user_pvt_solver->get_longitude() << " [deg], Height = " << d_user_pvt_solver->get_height() << " [m], with GDOP = " << d_user_pvt_solver->get_gdop() - << solution_status_tag(displayed_solution_status()); + << solution_status_tag(d_user_pvt_solver->pvt_sol.stat); std::cout << ss.str() << std::endl; d_end = std::chrono::system_clock::now(); std::chrono::duration elapsed_seconds = d_end - d_start; @@ -3155,7 +3208,7 @@ int rtklib_pvt_gs::work(int noutput_items, gr_vector_const_void_star& input_item std::cout.setf(std::ios::fixed, std::ios::floatfield); auto* facet = new boost::posix_time::time_facet("%Y-%b-%d %H:%M:%S.%f %z"); std::cout.imbue(std::locale(std::cout.getloc(), facet)); - const int solq = displayed_solution_status(); + const int solq = d_user_pvt_solver->pvt_sol.stat; const std::string solq_tag = solution_status_tag(solq); const std::string solq_txt = solq_tag.empty() ? "" : solution_status_color(solq) + solq_tag; std::cout diff --git a/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.h b/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.h index ce72dd8dc..e1ecc43fa 100644 --- a/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.h +++ b/src/algorithms/PVT/gnuradio_blocks/rtklib_pvt_gs.h @@ -65,6 +65,7 @@ class Monitor_Pvt_Udp_Sink; class Monitor_Ephemeris_Udp_Sink; class Nmea_Printer; class Ntrip_Rtcm_Client; +struct Ntrip_Rtcm_Snapshot; class Pvt_Conf; class Rinex_Printer; class Rtcm_Printer; @@ -168,7 +169,7 @@ private: void report_solution_status(); - int displayed_solution_status() const; + void report_solution_outage(); std::map interpolate_observables(const std::map& observables_map_t0, const std::map& observables_map_t1, @@ -199,6 +200,10 @@ private: std::unique_ptr d_gpx_dump; std::unique_ptr d_nmea_printer; std::unique_ptr d_ntrip_client; + // Cached copy of the client's latest snapshot, refreshed only when the + // client's snapshot generation changes (the deep copy is expensive at the + // observables rate). + std::unique_ptr d_fixed_base_snapshot; std::unique_ptr d_geojson_printer; std::unique_ptr d_rtcm_printer; std::unique_ptr d_udp_sink_ptr; @@ -289,6 +294,8 @@ private: uint32_t d_pvt_errors_counter; int d_last_ntrip_client_state; int d_last_fixed_base_status; + uint64_t d_last_ntrip_state_generation = 0; + uint64_t d_ntrip_snapshot_generation = 0; int d_last_solution_status; bool d_dump; diff --git a/src/algorithms/PVT/libs/CMakeLists.txt b/src/algorithms/PVT/libs/CMakeLists.txt index 139cca270..d610d7e24 100644 --- a/src/algorithms/PVT/libs/CMakeLists.txt +++ b/src/algorithms/PVT/libs/CMakeLists.txt @@ -39,6 +39,8 @@ set(PVT_LIB_HEADERS kml_printer.h nmea_printer.h ntrip_rtcm_client.h + ntrip_rtk_signals.h + secure_string.h rinex_printer.h rtcm_printer.h rtcm.h diff --git a/src/algorithms/PVT/libs/ntrip_rtcm_client.cc b/src/algorithms/PVT/libs/ntrip_rtcm_client.cc index 0c4dabff3..f1c7bfaad 100644 --- a/src/algorithms/PVT/libs/ntrip_rtcm_client.cc +++ b/src/algorithms/PVT/libs/ntrip_rtcm_client.cc @@ -63,63 +63,40 @@ bool valid_ecef_position(const std::array& position) } +/* Band membership comes from RTKLIB's own code table (obsfreqs[] via + code2obs() in rtklib_rtkcmn.cc), so a code added there - as this branch + itself did for B1C's CODE_L1D - is accepted here without a parallel edit. + The caller has already filtered by satellite system, which restricts the + codes the RTCM decoder can produce to that system's signal set. */ +bool code_in_band(unsigned char code, int band) +{ + int freq = 0; + code2obs(code, &freq); + return freq == band; +} + + bool supported_gps_l1_code(unsigned char code) { - switch (code) - { - case CODE_L1C: - case CODE_L1P: - case CODE_L1W: - case CODE_L1Y: - case CODE_L1M: - case CODE_L1N: - case CODE_L1S: - case CODE_L1L: - case CODE_L1X: - return true; - default: - return false; - } + return code_in_band(code, 1); } bool supported_gps_l2_code(unsigned char code) { - switch (code) - { - case CODE_L2C: - case CODE_L2D: - case CODE_L2S: - case CODE_L2L: - case CODE_L2X: - case CODE_L2P: - case CODE_L2W: - case CODE_L2Y: - case CODE_L2M: - case CODE_L2N: - return true; - default: - return false; - } + return code_in_band(code, 2); } bool supported_gal_e1_code(unsigned char code) { - switch (code) - { - case CODE_L1A: - case CODE_L1B: - case CODE_L1C: - case CODE_L1X: - case CODE_L1Z: - return true; - default: - return false; - } + return code_in_band(code, 1); } +// Deliberately an explicit list, not a band lookup: this is the GNSS-SDR +// prefer-B1C rule for RTKLIB's shared first slot (B1I satellites are +// dropped), not band membership bool supported_bds_b1c_code(unsigned char code) { switch (code) @@ -134,18 +111,10 @@ bool supported_bds_b1c_code(unsigned char code) } -// GPS L5 and Galileo E5a share the observation codes of RTKLIB's L5 band +// GPS L5 and Galileo E5a share RTKLIB's L5/E5a band bool supported_l5_band_code(unsigned char code) { - switch (code) - { - case CODE_L5I: - case CODE_L5Q: - case CODE_L5X: - return true; - default: - return false; - } + return code_in_band(code, 3); } @@ -233,8 +202,8 @@ public: if (d_stream.port != nullptr) { auto* ntrip = static_cast(d_stream.port); - std::memset(ntrip->user, 0, sizeof(ntrip->user)); - std::memset(ntrip->passwd, 0, sizeof(ntrip->passwd)); + secure_wipe(ntrip->user, sizeof(ntrip->user)); + secure_wipe(ntrip->passwd, sizeof(ntrip->passwd)); // Keep unopened and disconnected transports explicitly invalid // before handing ownership back to RTKLIB's stream cleanup. if (ntrip->tcp != nullptr && ntrip->tcp->svr.state < 1) @@ -398,15 +367,23 @@ void secure_clear_string(std::string* value) noexcept { return; } - volatile char* data = &(*value)[0]; - for (std::size_t i = 0; i < value->size(); ++i) - { - data[i] = 0; - } + secure_wipe(&(*value)[0], value->size()); value->clear(); } +void secure_clear_ntrip_credentials(Pvt_Conf* conf) noexcept +{ + if (conf == nullptr) + { + return; + } + conf->ntrip_username.clear(); + conf->ntrip_password.clear(); + conf->ntrip_password_env.clear(); +} + + std::string Ntrip_Rtcm_Client_Config::validate() const { const std::string clean_mountpoint = normalized_mountpoint(mountpoint); @@ -499,8 +476,8 @@ Ntrip_Rtcm_Client_Config make_ntrip_rtcm_client_config(const Pvt_Conf& conf) config.host = conf.ntrip_caster_address; config.port = conf.ntrip_port; config.mountpoint = normalized_mountpoint(conf.ntrip_mountpoint); - config.username = conf.ntrip_username; - config.password = conf.ntrip_password; + config.username = conf.ntrip_username.value(); + config.password = conf.ntrip_password.value(); config.reconnect_interval_ms = conf.ntrip_reconnect_interval_ms; config.timeout_ms = conf.ntrip_timeout_ms; config.max_age_s = conf.ntrip_max_correction_age_s; @@ -644,6 +621,17 @@ public: return result; } + std::uint64_t state_generation() const noexcept + { + return d_state_generation.load(std::memory_order_relaxed); + } + + std::uint64_t snapshot_generation() const + { + std::lock_guard lock(d_output_mutex); + return d_output_generation; + } + Ntrip_Rtcm_Snapshot latest_snapshot() const { return latest_snapshot(utc2gpst(timeget())); @@ -657,14 +645,14 @@ public: result.has_observations = d_has_observations; result.observations = d_observations; result.observation_time = d_observation_time; - result.observation_generation = d_observation_generation; + result.generation = d_output_generation; result.has_base_position = d_has_base_position; result.base_position_ecef_m = d_base_position_ecef_m; result.base_position_message_type = d_base_position_message_type; result.station_id = d_has_observations ? d_observation_station_id : d_base_position_station_id; result.itrf = d_base_position_itrf; result.antenna_height_m = d_antenna_height_m; - result.gps_ephemerides = d_gps_ephemerides; + result.ephemerides = d_ephemerides; } if (result.has_observations) @@ -822,6 +810,12 @@ private: } if (!complete) { + // Abandon the stalled resolution so the next reconnect cycle + // spawns a fresh lookup instead of re-waiting forever on this + // one: a getaddrinfo() stuck on a dead DNS server would + // otherwise wedge reconnection until receiver restart. The + // detached thread keeps its own shared_ptr to the state. + pending_resolution->reset(); *failure_message = "NTRIP caster hostname resolution timed out"; return Resolve_Result::FAILED; } @@ -1034,6 +1028,20 @@ private: } } + /* Evidence that the caster cannot speak NTRIP v2 on this connection and + a fresh-connection v1 retry is warranted: it explicitly asked for v1, + answered in v1, or dropped the request without any response bytes. + Single home of the heuristic, shared by the connection-closed and + timeout paths of perform_handshake() - a refinement applied to only + one of them would make the two downgrade under different rules. */ + static bool v1_downgrade_indicated(const ntrip_t* ntrip, int requested_version) + { + return requested_version == NTRIP_VERSION_2 && + (ntrip->v1_retry_requested != 0 || + ntrip->version == NTRIP_VERSION_1 || + ntrip->response_received == 0); + } + Handshake_Result perform_handshake(stream_t* stream, int requested_version) { auto* ntrip = static_cast(stream->port); @@ -1069,10 +1077,7 @@ private: // waitntrip() call, before connected can be latched. if (ntrip->request_sent != 0) { - if (requested_version == NTRIP_VERSION_2 && - (ntrip->v1_retry_requested != 0 || - ntrip->version == NTRIP_VERSION_1 || - ntrip->response_received == 0)) + if (v1_downgrade_indicated(ntrip, requested_version)) { return Handshake_Result::RETRY_V1; } @@ -1103,10 +1108,7 @@ private: { return Handshake_Result::SEND_FAILED; } - if (requested_version == NTRIP_VERSION_2 && - (ntrip->v1_retry_requested != 0 || - ntrip->version == NTRIP_VERSION_1 || - ntrip->response_received == 0)) + if (v1_downgrade_indicated(ntrip, requested_version)) { return Handshake_Result::RETRY_V1; } @@ -1120,16 +1122,29 @@ private: std::array buffer = {{0}}; bool sent_gga = false; std::chrono::steady_clock::time_point last_gga; + std::chrono::steady_clock::time_point last_data = std::chrono::steady_clock::now(); while (!d_stop_requested.load()) { apply_decoder_time(decoder, applied_rover_time_generation, false); const int count = strread(stream, buffer.data(), static_cast(buffer.size())); + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if (count > 0) { + last_data = now; add_received_bytes(static_cast(count)); decode_bytes(decoder, buffer.data(), count); } + else if (d_config.timeout_ms > 0 && + std::chrono::duration_cast(now - last_data).count() > + d_config.timeout_ms) + { + // The TCP-level inactivity watchdog counts writes too, so + // the periodic GGA upload keeps feeding it while a hung + // caster ACKs and sends nothing: enforce the timeout on + // received-data age here so the stream reconnects + return "correction stream inactive"; + } if (ntrip == nullptr || ntrip->tcp == nullptr || ntrip->tcp->svr.state != 2 || ntrip->state != 2) @@ -1139,7 +1154,6 @@ private: if (d_config.send_gga) { - const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if (!sent_gga || std::chrono::duration_cast(now - last_gga).count() >= d_config.gga_period_ms) @@ -1284,14 +1298,15 @@ private: d_observation_time = d_observations.front().time; d_observation_station_id = decoder.staid; d_has_observations = true; - ++d_observation_generation; + ++d_output_generation; } increment_observation_epochs(); } void store_ephemeris(const rtcm_t& decoder) { - if (decoder.ephsat <= 0 || satsys(decoder.ephsat, nullptr) != SYS_GPS || + const int system = decoder.ephsat > 0 ? satsys(decoder.ephsat, nullptr) : 0; + if ((system != SYS_GPS && system != SYS_GAL && system != SYS_BDS && system != SYS_QZS) || decoder.nav.eph == nullptr || decoder.ephsat > decoder.nav.n) { return; @@ -1303,42 +1318,25 @@ private: } std::lock_guard lock(d_output_mutex); - for (auto& d_gps_ephemeride : d_gps_ephemerides) + ++d_output_generation; + for (auto& stored_ephemeris : d_ephemerides) { - if (d_gps_ephemeride.sat == ephemeris.sat) + if (stored_ephemeris.sat == ephemeris.sat) { - d_gps_ephemeride = ephemeris; + stored_ephemeris = ephemeris; return; } } - d_gps_ephemerides.push_back(ephemeris); + d_ephemerides.push_back(ephemeris); } void store_base_position(const rtcm_t& decoder, int message_type) { - std::array position = {{decoder.sta.pos[0], decoder.sta.pos[1], decoder.sta.pos[2]}}; - double geodetic_position[3] = {0.0, 0.0, 0.0}; - double delta_ecef[3] = {0.0, 0.0, 0.0}; - ecef2pos(position.data(), geodetic_position); - if (decoder.sta.deltype != 0) - { - double antenna_height_enu[3] = {0.0, 0.0, decoder.sta.hgt}; - enu2ecef(geodetic_position, antenna_height_enu, delta_ecef); - for (std::size_t i = 0; i < position.size(); ++i) - { - position[i] += decoder.sta.del[i] + delta_ecef[i]; - } - } - else - { - enu2ecef(geodetic_position, decoder.sta.del, delta_ecef); - for (std::size_t i = 0; i < position.size(); ++i) - { - position[i] += delta_ecef[i]; - } - } + std::array position = {{0.0, 0.0, 0.0}}; + sta2antpos(&decoder.sta, position.data()); std::lock_guard lock(d_output_mutex); + ++d_output_generation; if (d_has_observations && d_observation_station_id != decoder.staid) { invalidate_observations_locked(); @@ -1369,10 +1367,12 @@ private: d_observations.clear(); d_observation_time = gtime_t{0, 0.0}; d_has_observations = false; - d_observation_generation = 0; + /* a reset is an output change too: never rewind the generation, or a + caller could match a stale cached snapshot after restart */ + ++d_output_generation; d_observation_station_id = 0; invalidate_base_position_locked(); - d_gps_ephemerides.clear(); + d_ephemerides.clear(); } void invalidate_observations_locked() @@ -1381,7 +1381,7 @@ private: d_observation_time = gtime_t{0, 0.0}; d_has_observations = false; d_observation_station_id = 0; - ++d_observation_generation; + ++d_output_generation; } void invalidate_base_position_locked() @@ -1434,6 +1434,7 @@ private: { std::lock_guard lock(d_status_mutex); d_status = Ntrip_Rtcm_Client_Status(); + d_state_generation.fetch_add(1, std::memory_order_relaxed); } void set_state(Ntrip_Rtcm_Client_State state, const std::string& message, @@ -1443,6 +1444,7 @@ private: d_status.state = state; d_status.connected = connected; d_status.message = message; + d_state_generation.fetch_add(1, std::memory_order_relaxed); } void add_received_bytes(std::uint64_t count) @@ -1487,6 +1489,7 @@ private: mutable std::mutex d_status_mutex; Ntrip_Rtcm_Client_Status d_status; + std::atomic d_state_generation{0}; mutable std::mutex d_input_mutex; std::array d_rover_position_ecef_m = {{0.0, 0.0, 0.0}}; @@ -1499,7 +1502,7 @@ private: std::vector d_observations; gtime_t d_observation_time = {0, 0.0}; bool d_has_observations = false; - std::uint64_t d_observation_generation = 0; + std::uint64_t d_output_generation = 0; int d_observation_station_id = 0; std::array d_base_position_ecef_m = {{0.0, 0.0, 0.0}}; bool d_has_base_position = false; @@ -1507,7 +1510,7 @@ private: int d_base_position_station_id = 0; int d_base_position_itrf = 0; double d_antenna_height_m = 0.0; - std::vector d_gps_ephemerides; + std::vector d_ephemerides; }; @@ -1556,6 +1559,18 @@ Ntrip_Rtcm_Snapshot Ntrip_Rtcm_Client::latest_snapshot(const gtime_t& rover_time } +std::uint64_t Ntrip_Rtcm_Client::snapshot_generation() const +{ + return d_impl->snapshot_generation(); +} + + +std::uint64_t Ntrip_Rtcm_Client::state_generation() const noexcept +{ + return d_impl->state_generation(); +} + + void Ntrip_Rtcm_Client::update_rover_position( const std::array& position_ecef_m) { diff --git a/src/algorithms/PVT/libs/ntrip_rtcm_client.h b/src/algorithms/PVT/libs/ntrip_rtcm_client.h index f794fc545..256435bd7 100644 --- a/src/algorithms/PVT/libs/ntrip_rtcm_client.h +++ b/src/algorithms/PVT/libs/ntrip_rtcm_client.h @@ -32,9 +32,16 @@ //! Overwrites a credential string before releasing its storage, so the //! secret does not linger in freed heap memory. Single implementation for -//! every holder of a Pvt_Conf / NTRIP configuration copy. +//! every holder of a Pvt_Conf / NTRIP configuration copy (backed by the +//! secure_wipe() primitive in rtklib_stream.h). void secure_clear_string(std::string* value) noexcept; +//! Scrubs the NTRIP credential fields of a Pvt_Conf copy ahead of its +//! destruction (the Secure_String fields wipe themselves on release, but a +//! long-lived copy should drop its secrets as soon as they are consumed). +//! Single home of the credential field list. +void secure_clear_ntrip_credentials(Pvt_Conf* conf) noexcept; + struct Ntrip_Rtcm_Client_Config { bool enabled = false; @@ -117,7 +124,9 @@ struct Ntrip_Rtcm_Snapshot // value, so a small future-dated base epoch is accepted as well. double age_s = 0.0; bool fresh = false; - std::uint64_t observation_generation = 0; + // Bumped on every output-side change (observations, base position, + // ephemerides, reset), so equal values mean an identical snapshot. + std::uint64_t generation = 0; bool has_base_position = false; std::array base_position_ecef_m = {{0.0, 0.0, 0.0}}; @@ -126,9 +135,10 @@ struct Ntrip_Rtcm_Snapshot int itrf = 0; double antenna_height_m = 0.0; - // Latest decoded GPS broadcast ephemeris for each satellite seen on the - // correction stream. The records own no dynamic memory. - std::vector gps_ephemerides; + // Latest decoded broadcast ephemeris for each GPS, Galileo, BeiDou or + // QZSS satellite seen on the correction stream (RTCM MT1019/1044/1045/ + // 1046/1042). The records own no dynamic memory. + std::vector ephemerides; }; @@ -164,6 +174,12 @@ public: Ntrip_Rtcm_Snapshot latest_snapshot() const; Ntrip_Rtcm_Snapshot latest_snapshot(const gtime_t& rover_time) const; + // Cheap change detectors so per-epoch callers can skip the deep copies: + // snapshot_generation() matches Ntrip_Rtcm_Snapshot::generation, and + // state_generation() changes whenever the status state/message is set. + std::uint64_t snapshot_generation() const; + std::uint64_t state_generation() const noexcept; + // Position is ECEF in metres. Invalid/non-finite positions disable GGA // until another valid position is supplied. void update_rover_position(const std::array& position_ecef_m); diff --git a/src/algorithms/PVT/libs/ntrip_rtk_signals.h b/src/algorithms/PVT/libs/ntrip_rtk_signals.h new file mode 100644 index 000000000..0e0d7f49d --- /dev/null +++ b/src/algorithms/PVT/libs/ntrip_rtk_signals.h @@ -0,0 +1,128 @@ +/*! + * \file ntrip_rtk_signals.h + * \brief Signal table of the NTRIP RTK path + * \author Carles Fernandez-Prades, 2026. cfernandez(at)cttc.es + * + * ----------------------------------------------------------------------------- + * + * GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + * This file is part of GNSS-SDR. + * + * SPDX-FileCopyrightText: 2026 (see AUTHORS file for a list of contributors) + * SPDX-License-Identifier: BSD-2-Clause + * + * ----------------------------------------------------------------------------- + */ + +#ifndef GNSS_SDR_NTRIP_RTK_SIGNALS_H +#define GNSS_SDR_NTRIP_RTK_SIGNALS_H + +#include "rtklib.h" +#include "signal_enabled_flags.h" +#include "signal_flag.h" +#include +#include + +/** \addtogroup PVT + * \{ */ +/** \addtogroup PVT_libs + * \{ */ + +/*! + * Single home for the signal knowledge of the NTRIP RTK path: which tracked + * signals it supports and, for each one, the constellation and the RTKLIB + * frequency slot it occupies. The adapter's channel-set whitelist, its + * derived slot count and navigation-system mask, and the solver's per-system + * band mapping all derive from this table, so admitting a new signal (e.g. + * BeiDou B3I or QZSS) starts with a one-row change here. + */ +struct Ntrip_Rtk_Signal +{ + uint32_t flag; //!< signal_flag bit of the tracked channel signal + int system; //!< RTKLIB constellation bit (SYS_XXX) + int band_slot; //!< RTKLIB frequency slot the signal occupies (0-based) + bool first_band; //!< entry band of its constellation (defines the navsys mask) +}; + + +/*! + * Keep each constellation's second bands ordered by preference: with more + * than one enabled (a configuration the adapter rejects today), + * ntrip_rtk_second_band_slot() picks the first match. + */ +inline constexpr std::array NTRIP_RTK_SIGNALS = {{ + {GPS_1C, SYS_GPS, 0, true}, // GPS L1 C/A + {GPS_2S, SYS_GPS, 1, false}, // GPS L2C + {GPS_L5, SYS_GPS, 2, false}, // GPS L5 (shares the third slot with E5a) + {GAL_1B, SYS_GAL, 0, true}, // Galileo E1 + {GAL_E5a, SYS_GAL, 2, false}, // Galileo E5a + {BDS_B1C, SYS_BDS, 0, true}, // BeiDou B1C +}}; + + +//! Bitmask of every signal_flag the NTRIP RTK path supports +inline constexpr uint32_t ntrip_rtk_supported_signal_mask() +{ + uint32_t mask = 0; + for (const auto& signal : NTRIP_RTK_SIGNALS) + { + mask |= signal.flag; + } + return mask; +} + + +//! RTKLIB navigation-system mask spanned by the enabled NTRIP RTK channels +inline int ntrip_rtk_navigation_systems(const Signal_Enabled_Flags& enabled) +{ + int navsys = 0; + for (const auto& signal : NTRIP_RTK_SIGNALS) + { + if (signal.first_band && enabled.check_any_enabled(signal.flag)) + { + navsys |= signal.system; + } + } + return navsys; +} + + +//! Number of RTKLIB frequency slots the enabled NTRIP RTK channels require +inline int ntrip_rtk_required_bands(const Signal_Enabled_Flags& enabled) +{ + int bands = 1; + for (const auto& signal : NTRIP_RTK_SIGNALS) + { + if (enabled.check_any_enabled(signal.flag) && signal.band_slot + 1 > bands) + { + bands = signal.band_slot + 1; + } + } + return bands; +} + + +/*! + * RTKLIB slot of a satellite system's second band given the enabled channels. + * QZSS rides the GPS bands. When no second band of the system is enabled, the + * constellation's canonical second slot is returned (Galileo/BeiDou: 2, both + * anticipate slot-2 signals; GPS/QZSS: 1) so unsupported slots can be cleared. + */ +inline int ntrip_rtk_second_band_slot(int system, const Signal_Enabled_Flags& enabled) +{ + const int lookup_system = (system == SYS_QZS) ? SYS_GPS : system; + for (const auto& signal : NTRIP_RTK_SIGNALS) + { + if (signal.system == lookup_system && !signal.first_band && + enabled.check_any_enabled(signal.flag)) + { + return signal.band_slot; + } + } + return (lookup_system == SYS_GAL || lookup_system == SYS_BDS) ? 2 : 1; +} + + +/** \} */ +/** \} */ +#endif // GNSS_SDR_NTRIP_RTK_SIGNALS_H diff --git a/src/algorithms/PVT/libs/pvt_conf.h b/src/algorithms/PVT/libs/pvt_conf.h index 3837a9faa..043135028 100644 --- a/src/algorithms/PVT/libs/pvt_conf.h +++ b/src/algorithms/PVT/libs/pvt_conf.h @@ -17,6 +17,7 @@ #ifndef GNSS_SDR_PVT_CONF_H #define GNSS_SDR_PVT_CONF_H +#include "secure_string.h" #include #include #include @@ -53,9 +54,13 @@ public: std::string log_source_timetag_file; std::string ntrip_caster_address; std::string ntrip_mountpoint; - std::string ntrip_username; - std::string ntrip_password; - std::string ntrip_password_env; + // Self-scrubbing: every copy of this struct wipes the caster credentials + // on release, so new holders need no manual scrub site (the explicit + // early scrubs via secure_clear_ntrip_credentials() remain worthwhile + // for long-lived copies) + Secure_String ntrip_username; + Secure_String ntrip_password; + Secure_String ntrip_password_env; uint32_t signal_enabled_flags = 0; uint32_t observable_interval_ms = 20; diff --git a/src/algorithms/PVT/libs/rtklib_solver.cc b/src/algorithms/PVT/libs/rtklib_solver.cc index a674c2a87..21c054ad7 100644 --- a/src/algorithms/PVT/libs/rtklib_solver.cc +++ b/src/algorithms/PVT/libs/rtklib_solver.cc @@ -38,6 +38,7 @@ #include "gnss_sdr_filesystem.h" #include "matlab_writter_helper.h" #include "ntrip_rtcm_client.h" +#include "ntrip_rtk_signals.h" #include "rtklib_rtkpos.h" #include "signal_enabled_flags.h" #include @@ -69,9 +70,7 @@ Rtklib_Solver::Rtklib_Solver(const rtk_t &rtk, { // Solver instances need correction policy but never caster credentials. // Remove secrets from this long-lived configuration copy immediately. - secure_clear_string(&d_conf.ntrip_username); - secure_clear_string(&d_conf.ntrip_password); - secure_clear_string(&d_conf.ntrip_password_env); + secure_clear_ntrip_credentials(&d_conf); // see freq index at src/algorithms/libs/rtklib/rtklib_rtkcmn.cc // function: satwavelen @@ -1473,18 +1472,11 @@ bool Rtklib_Solver::prepare_fixed_base_observations(const Ntrip_Rtcm_Snapshot &f int &rover_observation_count, int &base_observation_count) { - // The dual-band pair sits in slots 0/2 for Galileo (E1/E5a shares - // RTKLIB's L5 frequency index) and, for GPS, in slots 0/1 (L1/L2) or - // slots 0/2 (L1/L5) depending on the configured channel pair + // Per-constellation band-to-slot mapping, shared with the adapter's + // channel-set validation (see NTRIP_RTK_SIGNALS in ntrip_rtk_signals.h) const Signal_Enabled_Flags enabled_flags(d_signal_enabled_flags); - const int gps_second_band_slot = (!enabled_flags.check_any_enabled(GPS_2S) && - enabled_flags.check_any_enabled(GPS_L5)) - ? 2 - : 1; - const auto second_band_slot = [gps_second_band_slot](const obsd_t &observation) { - // Galileo E5a and a future BeiDou B3 both live in slot 2 - const int system = satsys(observation.sat, nullptr); - return (system == SYS_GAL || system == SYS_BDS) ? 2 : gps_second_band_slot; + const auto second_band_slot = [&enabled_flags](const obsd_t &observation) { + return ntrip_rtk_second_band_slot(satsys(observation.sat, nullptr), enabled_flags); }; const auto clear_unsupported_slots = [&second_band_slot](obsd_t &observation) { const int second = second_band_slot(observation); @@ -1533,29 +1525,39 @@ bool Rtklib_Solver::prepare_fixed_base_observations(const Ntrip_Rtcm_Snapshot &f std::sort(observations.begin(), observations.end(), [](const obsd_t &left, const obsd_t &right) { return left.sat < right.sat; }); - std::vector merged; - merged.reserve(observations.size()); - for (const auto &observation : observations) + /* in-place merge of same-satellite entries: no scratch allocation */ + std::size_t out = 0; + for (std::size_t in = 0; in < observations.size(); ++in) { - if (!merged.empty() && merged.back().sat == observation.sat) + if (out > 0 && observations[out - 1].sat == observations[in].sat) { - merge_observation(merged.back(), observation); + merge_observation(observations[out - 1], observations[in]); } else { - merged.push_back(observation); + if (out != in) + { + observations[out] = observations[in]; + } + ++out; } } - observations.swap(merged); + observations.resize(out); }; - std::vector rover_observations; + /* member scratch vectors: their capacity persists across epochs, so this + per-epoch path stops allocating once warmed up */ + std::vector &rover_observations = d_fixed_base_rover_scratch; + rover_observations.clear(); rover_observations.reserve(static_cast(rover_observation_count)); for (int index = 0; index < rover_observation_count; ++index) { obsd_t observation = d_obs_data[index]; + /* QZSS shares the GPS band slots and has its own AR group in the + engine, so its rover observations enter the relative solution */ if (observation.sat <= 0 || (satsys(observation.sat, nullptr) != SYS_GPS && + satsys(observation.sat, nullptr) != SYS_QZS && satsys(observation.sat, nullptr) != SYS_GAL && satsys(observation.sat, nullptr) != SYS_BDS)) { @@ -1573,10 +1575,11 @@ bool Rtklib_Solver::prepare_fixed_base_observations(const Ntrip_Rtcm_Snapshot &f { rover_observations.resize(MAXOBS); } - rover_observation_count = static_cast(rover_observations.size()); - base_observation_count = 0; - d_obs_data.fill({}); - std::copy(rover_observations.cbegin(), rover_observations.cend(), d_obs_data.begin()); + /* d_obs_data and the counts are rewritten only when the base data is + actually applied (at the end of this function): every early return + below leaves the full multi-constellation rover set untouched so the + single-point fallback solves with the same satellites as a receiver + without NTRIP */ if (!fixed_base.has_observations || rover_observations.empty()) { @@ -1612,7 +1615,8 @@ bool Rtklib_Solver::prepare_fixed_base_observations(const Ntrip_Rtcm_Snapshot &f return false; } - std::vector base_observations; + std::vector &base_observations = d_fixed_base_base_scratch; + base_observations.clear(); base_observations.reserve(fixed_base.observations.size()); for (auto observation : fixed_base.observations) { @@ -1642,7 +1646,7 @@ bool Rtklib_Solver::prepare_fixed_base_observations(const Ntrip_Rtcm_Snapshot &f base_observations.resize(MAXOBS); } d_fixed_base_common_satellites = base_observations.size(); - if (d_fixed_base_common_satellites < 4) + if (d_fixed_base_common_satellites < NTRIP_MIN_COMMON_SATELLITES) { d_fixed_base_status = Rtklib_Fixed_Base_Status::INSUFFICIENT_COMMON_SATELLITES; return false; @@ -1673,13 +1677,44 @@ bool Rtklib_Solver::prepare_fixed_base_observations(const Ntrip_Rtcm_Snapshot &f d_rtk.opt.rb[index] = d_fixed_base_position_ecef_m[index]; } + /* slots beyond the entry rover count are already zero (get_PVT clears the + whole array at epoch start), so only the leftover entry-rover slots the + filtered set no longer covers need clearing */ + const int previously_filled = rover_observation_count; + rover_observation_count = static_cast(rover_observations.size()); + std::copy(rover_observations.cbegin(), rover_observations.cend(), d_obs_data.begin()); std::copy(base_observations.cbegin(), base_observations.cend(), d_obs_data.begin() + rover_observation_count); base_observation_count = static_cast(base_observations.size()); + const int used = rover_observation_count + base_observation_count; + if (used < previously_filled) + { + std::fill(d_obs_data.begin() + used, d_obs_data.begin() + previously_filled, obsd_t{}); + } d_fixed_base_status = Rtklib_Fixed_Base_Status::APPLIED; return true; } +/* Latest base-stream broadcast ephemeris for a satellite, or nullptr. The + records are RTKLIB-format already (RTCM MT1019/1044/1045/1046/1042), so a + satellite the rover has not finished decoding can still contribute. */ +static const eph_t *find_base_ephemeris(const Ntrip_Rtcm_Snapshot *fixed_base, int sat) +{ + if (fixed_base == nullptr) + { + return nullptr; + } + for (const auto &candidate : fixed_base->ephemerides) + { + if (candidate.sat == sat) + { + return &candidate; + } + } + return nullptr; +} + + bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_map, double kf_update_interval_s, const SensorDataAggregator &sensor_data_aggregator, @@ -1725,8 +1760,8 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ const bool has_selected_galileo_ephemeris = select_galileo_ephemeris( gnss_observables_iter->second.PRN, sig_, obs_tow, selected_galileo_ephemeris, selected_from_reduced_ced); - if (!has_selected_galileo_ephemeris || - (!selected_from_reduced_ced && has_active_has_do_not_use(gal_str, prn, obs_tow))) + if (has_selected_galileo_ephemeris && + !selected_from_reduced_ced && has_active_has_do_not_use(gal_str, prn, obs_tow)) { break; } @@ -1760,6 +1795,31 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ clear_applied_has_phase_bias_discontinuity(applied_has_correction, prn); valid_obs++; } + else if (fixed_base != nullptr) + { + // the rover has not decoded this satellite's I/NAV yet, but + // the base stream may have delivered its broadcast ephemeris + // over NTRIP (RTCM MT1045/MT1046): use it so the satellite + // contributes without waiting for the page decoding. E5a + // joins once the rover ephemeris is available. + const eph_t *base_ephemeris = find_base_ephemeris(fixed_base, satno(SYS_GAL, prn)); + if (base_ephemeris != nullptr) + { + int gst_week = 0; + time2gst(base_ephemeris->toe, &gst_week); + eph_data[valid_obs] = *base_ephemeris; + obsd_t newobs{}; + d_obs_data[valid_obs + glo_valid_obs] = insert_obs_to_rtklib(newobs, + gnss_observables_iter->second, + gst_week, + d_rtklib_band_index.at(sig_)); + valid_obs++; + } + else + { + DLOG(INFO) << "No ephemeris data for SV " << gnss_observables_iter->second.PRN; + } + } else // the ephemeris are not available for this SV { DLOG(INFO) << "No ephemeris data for SV " << gnss_observables_iter->second.PRN; @@ -1932,22 +1992,14 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ clear_applied_has_phase_bias_discontinuity(applied_has_correction, prn); valid_obs++; } - else if (!is_qzss && fixed_base != nullptr) + else if (fixed_base != nullptr) { // the rover has not decoded this satellite's LNAV yet, but // the base stream may have delivered its broadcast ephemeris - // over NTRIP (RTCM MT1019, already in RTKLIB format): use it - // so the satellite contributes without waiting the ~30 s of - // subframe decoding - const eph_t *base_ephemeris = nullptr; - for (const auto &candidate : fixed_base->gps_ephemerides) - { - if (candidate.sat == sat) - { - base_ephemeris = &candidate; - break; - } - } + // over NTRIP (RTCM MT1019/MT1044, already in RTKLIB format): + // use it so the satellite contributes without waiting the + // ~30 s of subframe decoding + const eph_t *base_ephemeris = find_base_ephemeris(fixed_base, sat); if (base_ephemeris != nullptr) { eph_data[valid_obs] = *base_ephemeris; @@ -2160,6 +2212,29 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ d_rtklib_band_index.at(sig_)); valid_obs++; } + else if (fixed_base != nullptr) + { + // base-stream broadcast ephemeris substitution (RTCM + // MT1042; DNAV orbit, fine for satellite positions while + // the rover B-CNAV1 decode completes) + const eph_t *base_ephemeris = find_base_ephemeris(fixed_base, bds_sat); + if (base_ephemeris != nullptr) + { + int gps_week = 0; + time2gpst(base_ephemeris->toe, &gps_week); + eph_data[valid_obs] = *base_ephemeris; + obsd_t newobs{}; + d_obs_data[valid_obs + glo_valid_obs] = insert_obs_to_rtklib(newobs, + gnss_observables_iter->second, + gps_week, + d_rtklib_band_index.at(sig_)); + valid_obs++; + } + else + { + DLOG(INFO) << "No B-CNAV1 ephemeris data for SV " << gnss_observables_iter->second.PRN; + } + } else { DLOG(INFO) << "No B-CNAV1 ephemeris data for SV " << gnss_observables_iter->second.PRN; @@ -2597,6 +2672,17 @@ bool Rtklib_Solver::get_PVT(const std::map &gnss_observables_ } this->set_rx_pos({rx_position_and_time[0], rx_position_and_time[1], rx_position_and_time[2]}); // save ECEF position for the next iteration + /* RTKLIB stamps SOLQ_PPP whenever the PPP filter runs, even on + broadcast orbits and clocks; only claim PPP when precise + products are loaded. Demoting here - after the PPP dtr-unit + handling above, which must follow the filter's internal + state - keeps the console, Monitor, dump and NMEA outputs + consistent with each other */ + if (pvt_sol.stat == SOLQ_PPP && !has_precise_ephemeris()) + { + pvt_sol.stat = SOLQ_SINGLE; + } + // compute Ground speed and COG double ground_speed_ms = 0.0; std::array pos{}; diff --git a/src/algorithms/PVT/libs/rtklib_solver.h b/src/algorithms/PVT/libs/rtklib_solver.h index 613060395..ff545b251 100644 --- a/src/algorithms/PVT/libs/rtklib_solver.h +++ b/src/algorithms/PVT/libs/rtklib_solver.h @@ -112,6 +112,10 @@ enum class Rtklib_Fixed_Base_Status class Rtklib_Solver : public Pvt_Solution { public: + //! Minimum satellites common to rover and base (any constellation) for + //! the fixed-base merge; the solver-owned policy the display layer quotes + static constexpr int NTRIP_MIN_COMMON_SATELLITES = 4; + Rtklib_Solver(const rtk_t& rtk, const Pvt_Conf& conf, const std::string& dump_filename, @@ -224,6 +228,10 @@ private: void reset_relative_filter(); std::array d_obs_data{}; + // per-epoch scratch of prepare_fixed_base_observations(); members so + // their capacity is reused across epochs + std::vector d_fixed_base_rover_scratch; + std::vector d_fixed_base_base_scratch; std::array d_dop{}; std::map d_rtklib_freq_index; std::map d_rtklib_band_index; diff --git a/src/algorithms/PVT/libs/secure_string.h b/src/algorithms/PVT/libs/secure_string.h new file mode 100644 index 000000000..c4db6f8f3 --- /dev/null +++ b/src/algorithms/PVT/libs/secure_string.h @@ -0,0 +1,112 @@ +/*! + * \file secure_string.h + * \brief Self-scrubbing value type for credential-bearing configuration fields + * \author Carles Fernandez-Prades, 2026. cfernandez(at)cttc.es + * + * ----------------------------------------------------------------------------- + * + * GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + * This file is part of GNSS-SDR. + * + * Copyright (C) 2010-2026 (see AUTHORS file for a list of contributors) + * SPDX-License-Identifier: BSD-2-Clause + * + * ----------------------------------------------------------------------------- + */ + +#ifndef GNSS_SDR_SECURE_STRING_H +#define GNSS_SDR_SECURE_STRING_H + +#include +#include +#include + +/** \addtogroup PVT + * \{ */ +/** \addtogroup PVT_libs + * \{ */ + +/* Single wipe primitive of the code base, defined in rtklib_stream.cc. It is + * declared here directly so this header stays free of the heavy rtklib + * includes; the compiler rejects any signature divergence in translation + * units that also see rtklib_stream.h. */ +void secure_wipe(void* data, size_t size); + +/*! + * \brief Value type for credential-bearing configuration fields. + * + * Wipes its storage with volatile stores before every release - destruction, + * assignment, clear() - so every copy of a configuration struct that carries + * one scrubs itself, and a new holder of such a copy needs no manual scrub + * site. Assign-once usage is assumed: in-place growth of the underlying + * std::string is not protected. + */ +class Secure_String +{ +public: + Secure_String() = default; + + // Implicit by design: a drop-in for std::string configuration fields + Secure_String(std::string value) : d_value(std::move(value)) {} // NOLINT(google-explicit-constructor,hicpp-explicit-conversions) + Secure_String(const char* value) : d_value(value) {} // NOLINT(google-explicit-constructor,hicpp-explicit-conversions) + + Secure_String(const Secure_String& other) = default; + + Secure_String(Secure_String&& other) noexcept : d_value(std::move(other.d_value)) + { + other.clear(); + } + + Secure_String& operator=(const Secure_String& other) + { + if (this != &other) + { + clear(); + d_value = other.d_value; + } + return *this; + } + + Secure_String& operator=(Secure_String&& other) noexcept + { + if (this != &other) + { + clear(); + d_value = std::move(other.d_value); + other.clear(); + } + return *this; + } + + ~Secure_String() + { + clear(); + } + + void clear() noexcept + { + if (!d_value.empty()) + { + secure_wipe(&d_value[0], d_value.size()); + } + d_value.clear(); + } + + const std::string& value() const noexcept + { + return d_value; + } + + bool empty() const noexcept + { + return d_value.empty(); + } + +private: + std::string d_value; +}; + + +/** \} */ +/** \} */ +#endif // GNSS_SDR_SECURE_STRING_H diff --git a/src/algorithms/libs/rtklib/CMakeLists.txt b/src/algorithms/libs/rtklib/CMakeLists.txt index 05146176d..78ee75fe6 100644 --- a/src/algorithms/libs/rtklib/CMakeLists.txt +++ b/src/algorithms/libs/rtklib/CMakeLists.txt @@ -67,19 +67,25 @@ else() ) endif() +# Threads::Threads is PUBLIC because rtklib.h includes and exposes +# pthread_t/pthread_mutex_t members and the lock_t/rtk_lock macros to every +# consumer, which therefore needs the -pthread compile usage requirement too. target_link_libraries(algorithms_libs_rtklib + PUBLIC + Threads::Threads PRIVATE core_system_parameters algorithms_libs Armadillo::armadillo LAPACK::LAPACK BLAS::BLAS - Threads::Threads ) # Use the same OpenSSL-preferred/GnuTLS-fallback selection as the rest of -# GNSS-SDR. This also propagates USE_GNUTLS_FALLBACK when appropriate. -link_to_crypto_dependencies(algorithms_libs_rtklib) +# GNSS-SDR. PRIVATE: the crypto backend is used only in rtklib_tls.cc and no +# public header of this target exposes it, so consumers must not inherit the +# backend's include directories or USE_* definitions. +link_to_crypto_dependencies(algorithms_libs_rtklib PRIVATE) if(ENABLE_GLOG_AND_GFLAGS) target_link_libraries(algorithms_libs_rtklib PRIVATE Gflags::gflags Glog::glog) diff --git a/src/algorithms/libs/rtklib/rtklib.h b/src/algorithms/libs/rtklib/rtklib.h index 7ba511660..99c32ee74 100644 --- a/src/algorithms/libs/rtklib/rtklib.h +++ b/src/algorithms/libs/rtklib/rtklib.h @@ -300,6 +300,31 @@ const double EFACT_BDS = 1.0; //!< error factor: BeiDou const double EFACT_IRN = 1.5; //!< error factor: IRNSS const double EFACT_SBS = 3.0; //!< error factor: SBAS +//! Measurement error factor of a navigation system (single home of the +//! sys-to-EFACT mapping used by the single-point, relative and PPP error +//! models) +inline double sysefact(int sys) +{ + switch (sys) + { + case SYS_GLO: + return EFACT_GLO; + case SYS_GAL: + return EFACT_GAL; + case SYS_SBS: + return EFACT_SBS; + case SYS_QZS: + return EFACT_QZS; + case SYS_BDS: + return EFACT_BDS; + case SYS_IRN: + return EFACT_IRN; + case SYS_GPS: + default: + return EFACT_GPS; + } +} + const int MAXEXFILE = 1024; //!< max number of expanded files const double MAXSBSAGEF = 30.0; //!< max age of SBAS fast correction (s) const double MAXSBSAGEL = 1800.0; //!< max age of SBAS long term corr (s) @@ -1024,7 +1049,11 @@ typedef struct degraded-visibility scenarios */ /* AR management options from the demo5 RTKLIB fork. Value-initialized (all zeros) they reproduce the classic behavior: no AR filtering, no minimum-satellite gates, - no satellite exclusion cycling, no position-variance gate */ + no satellite exclusion cycling, no position-variance gate. PRCOPT_DEFAULT keeps + them at zero; note that the GNSS-SDR PVT adapter enables the demo5 defaults + instead (PVT.ar_filter=true, min_fix_sats=4, min_hold_sats=5, min_drop_sats=10, + ar_max_position_variance=0.25) - set those configuration options to zero/false + to recover the classic behavior */ int arfilter; /* AR filtering to reject newly-added sats on AR ratio degradation (0:off,1:on) */ int minfixsats; /* min number of sats to fix integer ambiguities (0:no limit) */ int minholdsats; /* min number of sats to hold integer ambiguities (0:no limit) */ diff --git a/src/algorithms/libs/rtklib/rtklib_pntpos.cc b/src/algorithms/libs/rtklib/rtklib_pntpos.cc index c91735a2a..81f382c9b 100644 --- a/src/algorithms/libs/rtklib/rtklib_pntpos.cc +++ b/src/algorithms/libs/rtklib/rtklib_pntpos.cc @@ -100,33 +100,9 @@ static int used_frequency_slot(const obsd_t *obs) double varerr(const prcopt_t *opt, const obsd_t *obs, double el, int sys) { - double fact = 1.0; + double fact = sysefact(sys); double varr; - switch (sys) - { - case SYS_GPS: - fact *= EFACT_GPS; - break; - case SYS_GLO: - fact *= EFACT_GLO; - break; - case SYS_GAL: - fact *= EFACT_GAL; - break; - case SYS_SBS: - fact *= EFACT_SBS; - break; - case SYS_QZS: - fact *= EFACT_QZS; - break; - case SYS_BDS: - fact *= EFACT_BDS; - break; - default: - fact *= EFACT_GPS; - break; - } if (el < VARERR_MIN_EL) { el = VARERR_MIN_EL; @@ -341,8 +317,12 @@ double prange(const obsd_t *obs, const nav_t *nav, const double *azel, } else if (obs->code[2] != CODE_NONE) { - i = 2; - j = 2; /* B3I-only */ + /* B3I-only: same index pattern as L5-only (empty first + band), so the second-band branch applies the B3I rules - + no TGD (DNAV timing reference) and the broadcast iono, + modeled at B1I, scaled by gamma13 via iono_scale */ + i = 0; + j = 2; } } else if (sys == SYS_GPS || sys == SYS_GLO || sys == SYS_QZS) diff --git a/src/algorithms/libs/rtklib/rtklib_ppp.cc b/src/algorithms/libs/rtklib/rtklib_ppp.cc index 1623e5a02..3d01f52b6 100644 --- a/src/algorithms/libs/rtklib/rtklib_ppp.cc +++ b/src/algorithms/libs/rtklib/rtklib_ppp.cc @@ -918,7 +918,7 @@ double varerr(int sat __attribute__((unused)), int sys, double el, int type, con { fact *= opt->eratio[0]; } - fact *= sys == SYS_GLO ? EFACT_GLO : (sys == SYS_SBS ? EFACT_SBS : EFACT_GPS); + fact *= sysefact(sys); if (opt->ionoopt == IONOOPT_IFLC) { fact *= 3.0; diff --git a/src/algorithms/libs/rtklib/rtklib_rtkcmn.cc b/src/algorithms/libs/rtklib/rtklib_rtkcmn.cc index e26114a8f..0a39b869e 100644 --- a/src/algorithms/libs/rtklib/rtklib_rtkcmn.cc +++ b/src/algorithms/libs/rtklib/rtklib_rtkcmn.cc @@ -2353,6 +2353,48 @@ void enu2ecef(const double *pos, const double *e, double *r) } +/* antenna phase-center position of a station record --------------------------- + * apply the antenna delta of an RTCM 1005/1006 station record to its stored + * reference-point position. Single home of this geodesy: an inconsistency + * between consumers would directly bias the RTK baseline + * args : sta_t *sta I station parameters (pos, del, deltype, hgt) + * double *rr O antenna phase-center position ecef (m) + * notes : deltype 0: del is an e/n/u offset; + * deltype 1: del is an ecef x/y/z offset and hgt an additional up + * offset + *-----------------------------------------------------------------------------*/ +void sta2antpos(const sta_t *sta, double *rr) +{ + double pos[3]; + double del[3] = {0.0, 0.0, 0.0}; + double dr[3]; + int i; + + for (i = 0; i < 3; i++) + { + rr[i] = sta->pos[i]; + } + ecef2pos(rr, pos); + if (sta->deltype) + { /* xyz + height */ + del[2] = sta->hgt; + enu2ecef(pos, del, dr); + for (i = 0; i < 3; i++) + { + rr[i] += sta->del[i] + dr[i]; + } + } + else + { /* enu */ + enu2ecef(pos, sta->del, dr); + for (i = 0; i < 3; i++) + { + rr[i] += dr[i]; + } + } +} + + /* transform covariance to local tangental coordinate -------------------------- * transform ecef covariance to local tangental coordinate * args : double *pos I geodetic position {lat, lon} (rad) diff --git a/src/algorithms/libs/rtklib/rtklib_rtkcmn.h b/src/algorithms/libs/rtklib/rtklib_rtkcmn.h index c1437936b..e6bc42028 100644 --- a/src/algorithms/libs/rtklib/rtklib_rtkcmn.h +++ b/src/algorithms/libs/rtklib/rtklib_rtkcmn.h @@ -199,6 +199,8 @@ void pos2ecef(const double *pos, double *r); void xyz2enu(const double *pos, double *E); void ecef2enu(const double *pos, const double *r, double *e); void enu2ecef(const double *pos, const double *e, double *r); + +void sta2antpos(const sta_t *sta, double *rr); void covenu(const double *pos, const double *P, double *Q); void covecef(const double *pos, const double *Q, double *P); void ast_args(double t, double *f); diff --git a/src/algorithms/libs/rtklib/rtklib_rtkpos.cc b/src/algorithms/libs/rtklib/rtklib_rtkpos.cc index 98a00c147..eff47fb67 100644 --- a/src/algorithms/libs/rtklib/rtklib_rtkpos.cc +++ b/src/algorithms/libs/rtklib/rtklib_rtkpos.cc @@ -617,30 +617,7 @@ double varerr(int sat __attribute((unused)), int sys, double el, fact = opt->eratio[0]; } /* adjust variance for constellation */ - switch (sys) - { - case SYS_GPS: - fact *= EFACT_GPS; - break; - case SYS_GLO: - fact *= EFACT_GLO; - break; - case SYS_GAL: - fact *= EFACT_GAL; - break; - case SYS_SBS: - fact *= EFACT_SBS; - break; - case SYS_QZS: - fact *= EFACT_QZS; - break; - case SYS_BDS: - fact *= EFACT_BDS; - break; - default: - fact *= EFACT_GPS; - break; - } + fact *= sysefact(sys); a = fact * opt->err[1]; b = fact * opt->err[2]; } @@ -1176,7 +1153,11 @@ void detslp_dop(rtk_t *rtk, const obsd_t *obs, const int *ix, int ns, int rcv, sat = obs[ix[i]].sat; for (f = 0; f < nf; f++) { - if (dt[i][f] == 0.0) + /* skip entries whose difference was never computed (guards + above): dt alone is set before the DTTOL and wavelength + checks, so testing it would evaluate those entries as + dopdif = 0 against the median (demo5 tests dopdif) */ + if (dopdif[i][f] == 0.0) { continue; } @@ -1813,10 +1794,16 @@ int ddres(rtk_t *rtk, const obsd_t *obs, const nav_t *nav, double dt, const doub { frq = f % nf; - /* choose the minimum-variance satellite without a slip as - the reference (demo5: better than highest elevation in - urban conditions, where SNR is the better quality proxy) */ + /* choose the minimum-variance satellite as the reference + (demo5: better than highest elevation in urban + conditions, where SNR is the better quality proxy), + preferring one without a slip and with a valid lock; + one pass tracks the best clean and the best overall + candidate, and the overall one is used only if no + clean reference exists */ minvar = 0.0; + double minvar_any = 0.0; + int ref_any = -1; for (i = -1, j = 0; j < ns; j++) { sysj = rtk->ssat[sat[j] - 1].sys; @@ -1828,47 +1815,29 @@ int ddres(rtk_t *rtk, const obsd_t *obs, const nav_t *nav, double dt, const doub { continue; } - if (rtk->ssat[sat[j] - 1].slip[frq] & 1) - { - continue; - } - if (rtk->ssat[sat[j] - 1].lock[frq] < 0) - { - continue; - } refvar = varerr(sat[j], sysj, azel[1 + iu[j] * 2], rtk->ssat[sat[j] - 1].snr_rover[frq], rtk->ssat[sat[j] - 1].snr_base[frq], bl, dt, f, opt, &obs[iu[j]]); + if (ref_any < 0 || refvar < minvar_any) + { + ref_any = j; + minvar_any = refvar; + } + if ((rtk->ssat[sat[j] - 1].slip[frq] & 1) || + rtk->ssat[sat[j] - 1].lock[frq] < 0) + { + continue; + } if (i < 0 || refvar < minvar) { i = j; minvar = refvar; } } - /* fall back to a slipped satellite only if no clean reference exists */ if (i < 0) - { - minvar = 0.0; - for (j = 0; j < ns; j++) - { - sysj = rtk->ssat[sat[j] - 1].sys; - if (!test_sys(sysj, m) || sysj == SYS_SBS) - { - continue; - } - if (!validobs(iu[j], ir[j], f, nf, y)) - { - continue; - } - refvar = varerr(sat[j], sysj, azel[1 + iu[j] * 2], - rtk->ssat[sat[j] - 1].snr_rover[frq], rtk->ssat[sat[j] - 1].snr_base[frq], - bl, dt, f, opt, &obs[iu[j]]); - if (i < 0 || refvar < minvar) - { - i = j; - minvar = refvar; - } - } + { /* fall back to a slipped/unlocked reference */ + i = ref_any; + minvar = minvar_any; } if (i < 0) { @@ -2022,10 +1991,10 @@ int ddres(rtk_t *rtk, const obsd_t *obs, const nav_t *nav, double dt, const doub sat[i], sat[j], f < nf ? "L" : "P", f % nf + 1, v[nv]); continue; } - /* single-differenced measurement error variances */ - Ri[nv] = varerr(sat[i], sysi, azel[1 + iu[i] * 2], - rtk->ssat[sat[i] - 1].snr_rover[frq], rtk->ssat[sat[i] - 1].snr_base[frq], - bl, dt, f, opt, &obs[iu[i]]); + /* single-differenced measurement error variances; + the reference variance was already computed by + the selection loop and is invariant in j */ + Ri[nv] = minvar; Rj[nv] = varerr(sat[j], sysj, azel[1 + iu[j] * 2], rtk->ssat[sat[j] - 1].snr_rover[frq], rtk->ssat[sat[j] - 1].snr_base[frq], bl, dt, f, opt, &obs[iu[j]]); @@ -2196,7 +2165,7 @@ double intpres(gtime_t time, const obsd_t *obs, int n, const nav_t *nav, /* single to double-difference transformation matrix (D') --------------------*/ /* index of single-to-double-difference transformation (demo5/RTKLIB 2.4.3 style): returns pairs of phase-bias state indices {ref, target} instead of a D matrix */ -int ddidx(rtk_t *rtk, int *ix, int gps, int glo, int sbs) +int ddidx(rtk_t *rtk, int *ix, int glo, int sbs) { int i; int j; @@ -2209,7 +2178,7 @@ int ddidx(rtk_t *rtk, int *ix, int gps, int glo, int sbs) int nf = NF_RTK(&rtk->opt); int nofix; - trace(3, "ddidx: gps=%d glo=%d/%d sbs=%d\n", gps, glo, rtk->opt.glomodear, sbs); + trace(3, "ddidx: glo=%d/%d sbs=%d\n", glo, rtk->opt.glomodear, sbs); /* clear fix flag for all sats (1=float, 2=fix) */ for (i = 0; i < MAXSAT; i++) @@ -2221,8 +2190,10 @@ int ddidx(rtk_t *rtk, int *ix, int gps, int glo, int sbs) } for (m = 0; m < 5; m++) { /* m=0:gps/sbs, 1:glo, 2:gal, 3:bds, 4:qzs */ - /* skip if ambiguity resolution turned off for this sys */ - nofix = (m == 0 && gps == 0) || (m == 1 && glo == 0) || (m == 3 && rtk->opt.bdsmodear == 0); + /* skip if ambiguity resolution turned off for this sys + (GPS AR is never disabled here: ARMODE_OFF is handled upstream + in manage_amb_LAMBDA) */ + nofix = (m == 1 && glo == 0) || (m == 3 && rtk->opt.bdsmodear == 0); /* step through freqs */ for (f = 0, k = na; f < nf; f++, k += MAXSAT) @@ -2429,13 +2400,33 @@ void holdamb(rtk_t *rtk, const double *xa) } +/* count satellites with an ambiguity flagged for AR (fix == 2) on at least + one frequency; sat == nullptr scans the whole satellite table ------------*/ +static int count_fixed_sats(const rtk_t *rtk, const int *sat, int ns, int nf) +{ + int count = 0; + for (int i = 0; i < ns; i++) + { + const int index = (sat != nullptr ? sat[i] : i + 1) - 1; + for (int f = 0; f < nf; f++) + { + if (rtk->ssat[index].fix[f] == 2) + { + count++; + break; + } + } + } + return count; +} + + /* resolve integer ambiguity by LAMBDA ---------------------------------------*/ -int resamb_LAMBDA(rtk_t *rtk, double *bias, double *xa, int gps, int glo, int sbs) +int resamb_LAMBDA(rtk_t *rtk, double *bias, double *xa, int glo, int sbs) { prcopt_t *opt = &rtk->opt; int i; int j; - int f; int ns; int nb; int nb1; @@ -2461,21 +2452,10 @@ int resamb_LAMBDA(rtk_t *rtk, double *bias, double *xa, int gps, int glo, int sb /* index of single to double-difference transformation matrix (D'), used to translate phase biases to double differences */ ix = imat(nx, 2); - nb = ddidx(rtk, ix, gps, glo, sbs); + nb = ddidx(rtk, ix, glo, sbs); /* count unique sats used for AR on at least one frequency */ - ns = 0; - for (i = 0; i < MAXSAT; i++) - { - for (f = 0; f < NF_RTK(opt); f++) - { - if (rtk->ssat[i].fix[f] == 2) - { - ns++; - break; - } - } - } + ns = count_fixed_sats(rtk, nullptr, MAXSAT, NF_RTK(opt)); if (nb <= 0 || ns < opt->minfixsats) { errmsg(rtk, "not enough valid sats for AR: nb=%d ns=%d min=%d\n", @@ -2610,6 +2590,8 @@ int resamb_LAMBDA(rtk_t *rtk, double *bias, double *xa, int gps, int glo, int sb else { errmsg(rtk, "lambda error (info=%d)\n", info); + nb = 0; /* do not report the pair count as fixed ambiguities: bias[] + and xa were never written (demo5 zeroes nb here too) */ } free(ix); free(y); @@ -2633,12 +2615,9 @@ int manage_amb_LAMBDA(rtk_t *rtk, double *bias, double *xa, const int *sat, int f; int lockc[NFREQ]; int excsat = 0; - int nbsat = 0; - int gps1; + int nbsat; int glo1; int sbas1; - int glo2; - int sbas2; int nb; int rerun; int dly; @@ -2668,17 +2647,7 @@ int manage_amb_LAMBDA(rtk_t *rtk, double *bias, double *xa, const int *sat, return 0; } /* if no fix on previous sample and enough sats, cycle the excluded sat */ - for (i = 0; i < ns; i++) - { - for (f = 0; f < nf; f++) - { - if (rtk->ssat[sat[i] - 1].fix[f] == 2) - { - nbsat++; - break; - } - } - } + nbsat = count_fixed_sats(rtk, sat, ns, nf); if (rtk->opt.mindropsats > 0 && rtk->sol.prev_ratio2 < rtk->sol.thres && nbsat >= rtk->opt.mindropsats) { @@ -2729,8 +2698,6 @@ int manage_amb_LAMBDA(rtk_t *rtk, double *bias, double *xa, const int *sat, rtk->excsat = excsat; } - /* for the initial ambiguity resolution attempt, include all enabled sats */ - gps1 = 1; /* enable GLO AR only if configured and, for fix-and-hold, once hold has been acquired */ glo1 = ((rtk->opt.navsys & SYS_GLO) != 0 && rtk->opt.glomodear != 0 && (rtk->opt.glomodear != 3 || rtk->holdamb)) @@ -2739,7 +2706,7 @@ int manage_amb_LAMBDA(rtk_t *rtk, double *bias, double *xa, const int *sat, sbas1 = (rtk->opt.navsys & SYS_GLO) != 0 ? glo1 : (((rtk->opt.navsys & SYS_SBS) != 0) ? 1 : 0); /* first attempt to resolve ambiguities */ - nb = resamb_LAMBDA(rtk, bias, xa, gps1, glo1, sbas1); + nb = resamb_LAMBDA(rtk, bias, xa, glo1, sbas1); ratio1 = rtk->sol.ratio; /* reject bad satellites if AR filtering enabled */ @@ -2778,18 +2745,15 @@ int manage_amb_LAMBDA(rtk_t *rtk, double *bias, double *xa, const int *sat, if (rerun) { trace(3, "rerun AR with new sats removed\n"); - nb = resamb_LAMBDA(rtk, bias, xa, gps1, glo1, sbas1); + nb = resamb_LAMBDA(rtk, bias, xa, glo1, sbas1); } } - /* if fix-and-hold glomodear enabled, re-run AR with GLO/SBS disabled if it differs */ - if ((rtk->opt.navsys & SYS_GLO) != 0 && rtk->opt.glomodear == 3 && rtk->sol.ratio < rtk->sol.thres) + /* if fix-and-hold glomodear enabled, re-run AR with GLO/SBS disabled if it was enabled */ + if ((rtk->opt.navsys & SYS_GLO) != 0 && rtk->opt.glomodear == 3 && rtk->sol.ratio < rtk->sol.thres && + glo1 != 0) { - glo2 = sbas2 = 0; - if (glo1 != glo2) - { - nb = resamb_LAMBDA(rtk, bias, xa, 1, glo2, sbas2); - } + nb = resamb_LAMBDA(rtk, bias, xa, 0, 0); } /* restore the excluded sat if still no fix or no significant increase in AR ratio */ if (excsat != 0 && (rtk->sol.ratio < rtk->sol.thres) && @@ -2976,12 +2940,13 @@ int relpos(rtk_t *rtk, const obsd_t *obs, int nu, int nr, tracemat(4, rtk->x, 1, NR_RTK(opt), 13, 4); xp = mat(rtk->nx, 1); - Pp = zeros(rtk->nx, rtk->nx); + Pp = mat(rtk->nx, rtk->nx); xa = mat(rtk->nx, 1); matcpy(xp, rtk->x, rtk->nx, 1); /* the first ddres call reads Pp to detect freshly initialized phase biases (innovation-threshold opening), so it must see the real - covariance, not zeros (demo5) */ + covariance (demo5); this copy also fully initializes the mat() + allocation */ matcpy(Pp, rtk->P, rtk->nx, rtk->nx); ny = ns * nf * 2 + 2; @@ -3009,8 +2974,13 @@ int relpos(rtk_t *rtk, const obsd_t *obs, int nu, int nr, stat = SOLQ_NONE; break; } - /* kalman filter measurement update */ - matcpy(Pp, rtk->P, rtk->nx, rtk->nx); + /* kalman filter measurement update (on the first iteration Pp + already holds rtk->P from the pre-loop copy and nothing has + written either since) */ + if (i > 0) + { + matcpy(Pp, rtk->P, rtk->nx, rtk->nx); + } if ((info = filter(xp, Pp, H, v, R, rtk->nx, nv))) { errmsg(rtk, "filter error (info=%d)\n", info); diff --git a/src/algorithms/libs/rtklib/rtklib_rtkpos.h b/src/algorithms/libs/rtklib/rtklib_rtkpos.h index 956267560..14bafe630 100644 --- a/src/algorithms/libs/rtklib/rtklib_rtkpos.h +++ b/src/algorithms/libs/rtklib/rtklib_rtkpos.h @@ -156,13 +156,13 @@ double intpres(gtime_t time, const obsd_t *obs, int n, const nav_t *nav, rtk_t *rtk, double *y); -int ddidx(rtk_t *rtk, int *ix, int gps, int glo, int sbs); +int ddidx(rtk_t *rtk, int *ix, int glo, int sbs); void restamb(rtk_t *rtk, const double *bias, int nb, double *xa); void holdamb(rtk_t *rtk, const double *xa); -int resamb_LAMBDA(rtk_t *rtk, double *bias, double *xa, int gps, int glo, int sbs); +int resamb_LAMBDA(rtk_t *rtk, double *bias, double *xa, int glo, int sbs); int manage_amb_LAMBDA(rtk_t *rtk, double *bias, double *xa, const int *sat, int nf, int ns); diff --git a/src/algorithms/libs/rtklib/rtklib_rtksvr.cc b/src/algorithms/libs/rtklib/rtklib_rtksvr.cc index 3e93fecaa..35fecc009 100644 --- a/src/algorithms/libs/rtklib/rtklib_rtksvr.cc +++ b/src/algorithms/libs/rtklib/rtklib_rtksvr.cc @@ -175,9 +175,6 @@ void updatesvr(rtksvr_t *svr, int ret, obs_t *obs, nav_t *nav, int sat, geph_t *geph2; geph_t *geph3; // gtime_t tof; - double pos[3]; - double del[3] = {0}; - double dr[3]; int i; int n = 0; int prn; @@ -302,29 +299,8 @@ void updatesvr(rtksvr_t *svr, int ret, obs_t *obs, nav_t *nav, int sat, { /* antenna position parameters */ if (svr->rtk.opt.refpos == 4 && index == 1) { - for (i = 0; i < 3; i++) - { - svr->rtk.rb[i] = svr->rtcm[1].sta.pos[i]; - } /* antenna delta */ - ecef2pos(svr->rtk.rb, pos); - if (svr->rtcm[1].sta.deltype) - { /* xyz */ - del[2] = svr->rtcm[1].sta.hgt; - enu2ecef(pos, del, dr); - for (i = 0; i < 3; i++) - { - svr->rtk.rb[i] += svr->rtcm[1].sta.del[i] + dr[i]; - } - } - else - { /* enu */ - enu2ecef(pos, svr->rtcm[1].sta.del, dr); - for (i = 0; i < 3; i++) - { - svr->rtk.rb[i] += dr[i]; - } - } + sta2antpos(&svr->rtcm[1].sta, svr->rtk.rb); } svr->nmsg[index][4]++; } diff --git a/src/algorithms/libs/rtklib/rtklib_stream.cc b/src/algorithms/libs/rtklib/rtklib_stream.cc index 6bc3081e5..ae72a2e47 100644 --- a/src/algorithms/libs/rtklib/rtklib_stream.cc +++ b/src/algorithms/libs/rtklib/rtklib_stream.cc @@ -45,7 +45,6 @@ #include #include #include -#include #include #include #include @@ -57,6 +56,7 @@ #include #include #include +#include using namespace std::string_literals; @@ -361,19 +361,14 @@ void closefile_(file_t *file) } -/* open file (path=filepath[::T[::+][::x]][::S=swapintv]) --------*/ -file_t *openfile(std::string const &path, int mode, char *msg) +/* Split a stream path at the literal "::" separators, preserving empty + * tokens. Plain string search is used instead of std::regex, which is not + * functional in GCC < 4.9. Single tokenizer for every "::"-delimited stream + * path (file options, NTRIP client options): these paths carry credentials, + * so the index arithmetic lives in exactly one place. */ +static std::vector split_double_colon(const std::string &path) { - tracet(3, "openfile: path=%s mode=%d\n", path.data(), mode); - - if ((mode & (STR_MODE_R | STR_MODE_W)) == 0) - { - return nullptr; - } - - // Split the string at the literal "::" separators. Plain string search is - // used instead of std::regex, which is not functional in GCC < 4.9 - std::deque tokens; + std::vector tokens; std::string::size_type token_start = 0; for (;;) { @@ -386,19 +381,31 @@ file_t *openfile(std::string const &path, int mode, char *msg) tokens.push_back(path.substr(token_start, separator - token_start)); token_start = separator + 2; } + return tokens; +} + + +/* open file (path=filepath[::T[::+][::x]][::S=swapintv]) --------*/ +file_t *openfile(std::string const &path, int mode, char *msg) +{ + tracet(3, "openfile: path=%s mode=%d\n", path.data(), mode); + + if ((mode & (STR_MODE_R | STR_MODE_W)) == 0) + { + return nullptr; + } + + const std::vector tokens = split_double_colon(path); auto file = std::make_unique(); file->mode = mode; file->path = tokens.front(); // first token is the path - tokens.pop_front(); - /* file options */ - while (!tokens.empty()) + for (std::size_t token_index = 1; token_index < tokens.size(); ++token_index) { - auto tag = tokens.front(); - tokens.pop_front(); + const std::string &tag = tokens[token_index]; // edge case that may not be possible, but I don't want to test for right now if (tag.empty()) continue; // NOLINT(readability-braces-around-statements) @@ -1454,6 +1461,12 @@ int readtcpcli(tcpcli_t *tcpcli, unsigned char *buff, int n, char *msg) { return 0; } + if (n <= 0) + { + /* recv() returns 0 for a zero-length request too: without this + guard a full caller buffer would be misread as a peer close */ + return 0; + } nr = recv_nb(tcpcli->svr.sock, buff, n); if (nr == RECV_WOULD_BLOCK) @@ -1557,6 +1570,20 @@ int encbase64(char *str, const unsigned char *byte, int n) } +void secure_wipe(void *data, size_t size) +{ + if (data == nullptr || size == 0) + { + return; + } + volatile auto *bytes = static_cast(data); + for (size_t i = 0; i < size; ++i) + { + bytes[i] = 0; + } +} + + namespace { Rtklib_Tls_Client *ntrip_tls(ntrip_t *ntrip) @@ -1566,13 +1593,8 @@ Rtklib_Tls_Client *ntrip_tls(ntrip_t *ntrip) void ntrip_clear_request(ntrip_t *ntrip) { - /* Requests can contain caster credentials. Use volatile stores so clearing - * the persistent retry buffer is not optimized away. */ - volatile unsigned char *request = ntrip->request_buff; - for (std::size_t i = 0; i < sizeof(ntrip->request_buff); ++i) - { - request[i] = 0; - } + /* Requests can contain caster credentials */ + secure_wipe(ntrip->request_buff, sizeof(ntrip->request_buff)); ntrip->request_length = 0; ntrip->request_offset = 0; } @@ -2093,24 +2115,14 @@ int reject_ntrip_v2_response(ntrip_t *ntrip, char *msg, bool parse_ntrip_client_options(const char *path, std::string &clean_path, int &version, int &tls_enabled, char *msg) { - clean_path = path; version = NTRIP_VERSION_2; tls_enabled = 0; - const std::string::size_type first = clean_path.find("::"); - if (first == std::string::npos) + const std::vector tokens = split_double_colon(path); + clean_path = tokens.front(); + for (std::size_t token_index = 1; token_index < tokens.size(); ++token_index) { - return true; - } - - std::string options = clean_path.substr(first + 2); - clean_path.resize(first); - std::string::size_type begin = 0; - while (begin <= options.size()) - { - const std::string::size_type end = options.find("::", begin); - std::string option = options.substr(begin, - end == std::string::npos ? std::string::npos : end - begin); + std::string option = tokens[token_index]; std::transform(option.begin(), option.end(), option.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); @@ -2135,12 +2147,6 @@ bool parse_ntrip_client_options(const char *path, std::string &clean_path, std::snprintf(msg, MAXSTRMSG, "unknown NTRIP option"); return false; } - - if (end == std::string::npos) - { - break; - } - begin = end + 2; } return true; } @@ -2462,15 +2468,16 @@ int rspntrip_c(ntrip_t *ntrip, char *msg) { ntrip->buff[128] = '\0'; } - std::strncpy(msg, p, MAXSTRMSG); + std::snprintf(msg, MAXSTRMSG, "%s", p); tracet(1, "rspntrip_s: %s nb=%d\n", msg, ntrip->nb); ntrip->nb = 0; ntrip->buff[0] = '\0'; ntrip->state = 0; ntrip_disconnect(ntrip, ntrip->tcp->tirecon); } - else if (ntrip->nb >= NTRIP_MAXRSP) - { /* buffer overflow */ + else if (ntrip->nb >= NTRIP_MAXRSP - 1) + { /* buffer overflow (reads are capped at NTRIP_MAXRSP - nb - 1, so the + buffer is effectively full one byte before NTRIP_MAXRSP) */ std::snprintf(msg, MAXSTRMSG, "response overflow"); tracet(1, "rspntrip_s: response overflow nb=%d\n", ntrip->nb); ntrip->nb = 0; @@ -2505,6 +2512,16 @@ int waitntrip(ntrip_t *ntrip, char *msg) /* A new connection must send the complete request, never just the * suffix left from a short write on the previous socket. */ ntrip_clear_request(ntrip); + /* The response buffer and the chunked-transfer parser state belong + * to the previous connection too: leftover stream bytes ahead of + * the new HTTP response would make rspntrip_c parse garbage (and + * spuriously downgrade a v2 session to v1), and a stale chunked + * flag would de-frame a response that is not chunked. */ + ntrip->nb = 0; + ntrip->buff[0] = '\0'; + ntrip->chunked = 0; + ntrip->chunk_state = 0; + ntrip->chunk_remaining = 0; ntrip_reset_tls(ntrip); } @@ -2525,6 +2542,19 @@ int waitntrip(ntrip_t *ntrip, char *msg) } if (ntrip->state == 1) { /* read response */ + if (ntrip->nb >= NTRIP_MAXRSP - 1) + { /* the buffer filled without a recognizable status line: report + overflow instead of issuing a zero-length read (recv() would + return 0 and be misread as a peer disconnect; the TLS reader + would return 0 forever and stall until an external timeout) */ + std::snprintf(msg, MAXSTRMSG, "response overflow"); + tracet(1, "waitntrip: response overflow nb=%d\n", ntrip->nb); + ntrip->nb = 0; + ntrip->buff[0] = '\0'; + ntrip->state = 0; + ntrip_disconnect(ntrip, ntrip->tcp->tirecon); + return 0; + } p = reinterpret_cast(ntrip->buff) + ntrip->nb; if ((n = ntrip_read_transport(ntrip, reinterpret_cast(p), NTRIP_MAXRSP - ntrip->nb - 1, msg)) == 0) { @@ -2612,14 +2642,7 @@ ntrip_t *openntrip(const char *path, int type, char *msg) return nullptr; } std::string s_aux = "http://"s + std::string(tpath); - int n = s_aux.length(); - if (n < 256) - { - for (int k = 0; k < n; k++) - { - ntrip->url[k] = s_aux[k]; - } - } + std::snprintf(ntrip->url, sizeof(ntrip->url), "%s", s_aux.c_str()); std::strncpy(tpath, proxyaddr, MAXSTRPATH); } /* open tcp client stream */ @@ -2676,14 +2699,15 @@ int ntripsethost(ntrip_t *ntrip, const char *hostname, char *msg) std::snprintf(ntrip->host, sizeof(ntrip->host), "%s", hostname); if (ntrip->tls_enabled) { - auto *tls = new (std::nothrow) Rtklib_Tls_Client(hostname); - if (!tls || !tls->initialize(msg)) + /* retarget the existing context instead of building a second one: + the SSL context and CA store are host-independent, and the + hostname is consumed when the handshake starts */ + Rtklib_Tls_Client *tls = ntrip_tls(ntrip); + if (!tls || !tls->set_hostname(hostname)) { - delete tls; + std::snprintf(msg, MAXSTRMSG, "TLS host name update failed"); return 0; } - delete ntrip_tls(ntrip); - ntrip->tls_ctx = tls; } return 1; } diff --git a/src/algorithms/libs/rtklib/rtklib_stream.h b/src/algorithms/libs/rtklib/rtklib_stream.h index e227008d7..2b8c4e106 100644 --- a/src/algorithms/libs/rtklib/rtklib_stream.h +++ b/src/algorithms/libs/rtklib/rtklib_stream.h @@ -61,6 +61,11 @@ #define FTP_TIMEOUT 30 /* ftp/http timeout (s) */ +/* Overwrite credential-bearing memory with volatile stores so the wipe is not + * optimized away (a memset before free is a legal elision target). Single + * wipe primitive behind every credential scrub in the code base. */ +void secure_wipe(void *data, size_t size); + serial_t *openserial(const char *path, int mode, char *msg); void closeserial(serial_t *serial); diff --git a/src/algorithms/libs/rtklib/rtklib_tls.cc b/src/algorithms/libs/rtklib/rtklib_tls.cc index 163872f39..a6194ef0c 100644 --- a/src/algorithms/libs/rtklib/rtklib_tls.cc +++ b/src/algorithms/libs/rtklib/rtklib_tls.cc @@ -21,6 +21,7 @@ #include #include #include +#include #ifdef USE_GNUTLS_FALLBACK #include @@ -226,12 +227,13 @@ public: #endif // SSL_write() demands a retry after WANT_READ/WANT_WRITE to present - // the very same buffer and length, but the periodic GGA upload builds - // a fresh sentence on a fresh stack buffer each cycle: without these - // modes a backpressured GGA send is followed by a fatal - // SSL_R_BAD_WRITE_RETRY and a full NTRIP disconnect. With them a - // moved or shortened buffer is legal and partial writes report the - // bytes actually consumed, like a plain socket send + // the very same buffer and length. These modes make a moved buffer + // legal and let partial writes report the bytes actually consumed, + // but ssl3_write_pending() still rejects a retry SHORTER than the + // pending record with SSL_R_BAD_WRITE_RETRY, and an equal-or-longer + // retry silently transmits the cached record instead of the new data: + // write() below therefore keeps its own copy of a backpressured + // buffer and flushes exactly that copy before accepting new data SSL_CTX_set_mode(d_context, SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); SSL_CTX_set_verify(d_context, SSL_VERIFY_PEER, nullptr); @@ -262,6 +264,16 @@ public: #endif } + bool set_hostname(const std::string &hostname) + { + if (hostname.empty() || d_session != nullptr) + { + return false; + } + d_hostname = hostname; + return true; + } + void reset() { #ifdef USE_GNUTLS_FALLBACK @@ -277,6 +289,7 @@ public: SSL_free(d_session); d_session = nullptr; } + d_pending_record.clear(); #endif d_established = false; } @@ -540,6 +553,31 @@ public: set_tls_msg(msg, "TLS signal protection failed"); return -1; } + // OpenSSL keeps the record pending after WANT_READ/WANT_WRITE and + // rejects any retry shorter than it (SSL_R_BAD_WRITE_RETRY), even + // with SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER; an equal-or-longer retry + // silently transmits the cached record in place of the new data. + // Flush the stored copy of the backpressured buffer first: a stable- + // buffer retry (the handshake path) resends its own data exactly + // once, and a fresh GGA retry costs at most one stale-by-a-period + // sentence, with the report clamped to the caller's length + if (!d_pending_record.empty()) + { + const int pending = static_cast(d_pending_record.size()); + const int flushed = SSL_write(d_session, d_pending_record.data(), pending); + if (flushed <= 0) + { + const int flush_error = SSL_get_error(d_session, flushed); + if (flush_error == SSL_ERROR_WANT_READ || flush_error == SSL_ERROR_WANT_WRITE) + { + return 0; + } + set_tls_msg(msg, "OpenSSL send failed"); + return -1; + } + d_pending_record.clear(); + return flushed > n ? n : flushed; + } const int ret = SSL_write(d_session, buff, n); if (ret > 0) { @@ -548,6 +586,7 @@ public: const int ssl_error = SSL_get_error(d_session, ret); if (ssl_error == SSL_ERROR_WANT_READ || ssl_error == SSL_ERROR_WANT_WRITE) { + d_pending_record.assign(buff, buff + n); return 0; } set_tls_msg(msg, "OpenSSL send failed"); @@ -565,6 +604,7 @@ private: #else SSL_CTX *d_context = nullptr; SSL *d_session = nullptr; + std::vector d_pending_record; #endif }; @@ -590,6 +630,12 @@ void Rtklib_Tls_Client::reset() } +bool Rtklib_Tls_Client::set_hostname(const std::string &hostname) +{ + return d_impl->set_hostname(hostname); +} + + int Rtklib_Tls_Client::handshake(socket_t sock, char *msg) { return d_impl->handshake(sock, msg); diff --git a/src/algorithms/libs/rtklib/rtklib_tls.h b/src/algorithms/libs/rtklib/rtklib_tls.h index 8548270ec..bf467ea68 100644 --- a/src/algorithms/libs/rtklib/rtklib_tls.h +++ b/src/algorithms/libs/rtklib/rtklib_tls.h @@ -35,6 +35,12 @@ public: bool initialize(char *msg); void reset(); + // Replace the host name used for SNI and certificate verification. The + // SSL context and CA store are host-independent, so an initialized client + // can be retargeted without rebuilding them. Fails (returns false) once a + // TLS session exists: the hostname is consumed when the handshake starts. + bool set_hostname(const std::string &hostname); + // Return 1 when the TLS session is established, 0 while a non-blocking // handshake is in progress, and -1 on a fatal error. int handshake(socket_t sock, char *msg); diff --git a/src/core/libs/CMakeLists.txt b/src/core/libs/CMakeLists.txt index 0979bd538..d0f5bdd2a 100644 --- a/src/core/libs/CMakeLists.txt +++ b/src/core/libs/CMakeLists.txt @@ -134,7 +134,11 @@ target_include_directories(core_libs # links to the appropriate library and defines # USE_GNUTLS_FALLBACK, USE_OPENSSL_3, or USE_OPENSSL_111 accordingly. -link_to_crypto_dependencies(core_libs) +# PUBLIC scope (the default) is required here: the public header +# gnss_crypto.h includes the backend headers and switches the class +# definition on those macros, so every consumer must see the same +# defines and include directories. +link_to_crypto_dependencies(core_libs PUBLIC) if(USE_GENERIC_LAMBDAS) set(has_generic_lambdas HAS_GENERIC_LAMBDA=1) diff --git a/src/core/libs/supl/CMakeLists.txt b/src/core/libs/supl/CMakeLists.txt index 78ab2b80e..c4ce29129 100644 --- a/src/core/libs/supl/CMakeLists.txt +++ b/src/core/libs/supl/CMakeLists.txt @@ -48,7 +48,10 @@ endif() # links to the appropriate library and defines # USE_GNUTLS_FALLBACK, USE_OPENSSL_3, or USE_OPENSSL_111 accordingly. -link_to_crypto_dependencies(core_libs_supl) +# PUBLIC scope (the default) is required here: the public header supl.h +# includes the backend headers under those macros, so every consumer must +# see the same defines and include directories. +link_to_crypto_dependencies(core_libs_supl PUBLIC) target_include_directories(core_libs_supl PUBLIC diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ee0e87e68..dcb70222f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -720,6 +720,9 @@ if(ENABLE_UNIT_TESTING) system_testing_lib core_receiver ) + # rtklib_tls_test.cc drives the crypto backend directly (test TLS server), + # so link it explicitly instead of relying on transitive propagation + link_to_crypto_dependencies(run_tests PRIVATE) if(ENABLE_GLOG_AND_GFLAGS) target_link_libraries(run_tests PRIVATE Gflags::gflags Glog::glog) target_compile_definitions(run_tests PRIVATE -DUSE_GLOG_AND_GFLAGS=1) diff --git a/tests/test_main.cc b/tests/test_main.cc index a7659ea9b..261897922 100644 --- a/tests/test_main.cc +++ b/tests/test_main.cc @@ -167,15 +167,19 @@ private: #include "unit-tests/signal-processing-blocks/observables/observables_phase_continuity_test.cc" #include "unit-tests/signal-processing-blocks/osnma/gnss_crypto_test.cc" #include "unit-tests/signal-processing-blocks/osnma/osnma_msg_receiver_test.cc" +#include "unit-tests/signal-processing-blocks/pvt/bds_tgd_iono_test.cc" #include "unit-tests/signal-processing-blocks/pvt/geohash_test.cc" +#include "unit-tests/signal-processing-blocks/pvt/gps_tgd_isc_test.cc" #include "unit-tests/signal-processing-blocks/pvt/nmea_printer_test.cc" #include "unit-tests/signal-processing-blocks/pvt/ntrip_rtcm_client_test.cc" #include "unit-tests/signal-processing-blocks/pvt/rinex_printer_test.cc" #include "unit-tests/signal-processing-blocks/pvt/rtcm_printer_test.cc" #include "unit-tests/signal-processing-blocks/pvt/rtcm_test.cc" +#include "unit-tests/signal-processing-blocks/pvt/rtklib_detslp_dop_test.cc" #include "unit-tests/signal-processing-blocks/pvt/rtklib_fixed_base_test.cc" #include "unit-tests/signal-processing-blocks/pvt/rtklib_pvt_ntrip_configuration_test.cc" #include "unit-tests/signal-processing-blocks/pvt/rtklib_tls_test.cc" +#include "unit-tests/signal-processing-blocks/pvt/rtklib_udpos_test.cc" #include "unit-tests/signal-processing-blocks/pvt/sbas_rtklib_corrections_test.cc" #include "unit-tests/signal-processing-blocks/pvt/serdes_monitor_pvt_test.cc" #include "unit-tests/signal-processing-blocks/resampler/direct_resampler_conditioner_cc_test.cc" diff --git a/tests/unit-tests/signal-processing-blocks/pvt/bds_tgd_iono_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/bds_tgd_iono_test.cc new file mode 100644 index 000000000..ac0fde83e --- /dev/null +++ b/tests/unit-tests/signal-processing-blocks/pvt/bds_tgd_iono_test.cc @@ -0,0 +1,199 @@ +/*! + * \file bds_tgd_iono_test.cc + * \brief Tests for BeiDou B1I/B3I group-delay and ionospheric handling in SPP + * \author Carles Fernandez-Prades, 2026. cfernandez(at)cttc.es + * + * ----------------------------------------------------------------------------- + * + * GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + * This file is part of GNSS-SDR. + * + * Copyright (C) 2010-2026 (see AUTHORS file for a list of contributors) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * ----------------------------------------------------------------------------- + */ + +#include "rtklib_pntpos.h" +#include "rtklib_rtkcmn.h" +#include +#include + +namespace +{ +constexpr double TGD1_S = 5.0e-9; +constexpr double B1I_WAVELENGTH_M = SPEED_OF_LIGHT_M_S / FREQ1_BDS; +constexpr double B3I_WAVELENGTH_M = SPEED_OF_LIGHT_M_S / FREQ3_BDS; + +void set_bds_test_ephemeris(nav_t& nav, eph_t& eph, int sat) +{ + eph.sat = sat; + eph.tgd[0] = TGD1_S; // TGD1 (B1I), the B3I-referenced clock needs none + nav.n = 1; + nav.eph = &eph; +} +} // namespace + + +TEST(BdsTgdIonoTest, SingleFrequencyB1IAppliesMinusTgd1) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_BDS, 6); + ASSERT_GT(sat, 0); + set_bds_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = B1I_WAVELENGTH_M; + nav.lam[sat - 1][2] = B3I_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.code[0] = CODE_L2I; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + EXPECT_NEAR(obs.P[0] - SPEED_OF_LIGHT_M_S * TGD1_S, corrected_pseudorange, 1.0e-6); + EXPECT_DOUBLE_EQ(1.0, iono_scale); +} + + +TEST(BdsTgdIonoTest, SingleFrequencyB3IHasNoTgdAndScalesIonoByGamma13) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_BDS, 6); + ASSERT_GT(sat, 0); + set_bds_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = B1I_WAVELENGTH_M; + nav.lam[sat - 1][2] = B3I_WAVELENGTH_M; + obs.sat = sat; + obs.P[2] = 24000000.0; + obs.code[2] = CODE_L6I; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // B3I is the clock reference signal: no group-delay correction + EXPECT_NEAR(obs.P[2], corrected_pseudorange, 1.0e-6); + const double gamma13 = std::pow(B3I_WAVELENGTH_M / B1I_WAVELENGTH_M, 2.0); + EXPECT_DOUBLE_EQ(gamma13, iono_scale); +} + + +TEST(BdsTgdIonoTest, DualFrequencyB1IB3IRemovesTgd1BeforeCombining) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_BDS, 6); + ASSERT_GT(sat, 0); + set_bds_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = B1I_WAVELENGTH_M; + nav.lam[sat - 1][2] = B3I_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.P[2] = 24000006.0; + obs.code[0] = CODE_L2I; + obs.code[2] = CODE_L6I; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // The DNAV clock is referenced to B3I, so the iono-free combination must + // use the TGD1-corrected B1I pseudorange: + // PC = (gamma13*(P1 - c*TGD1) - P2) / (gamma13 - 1) + const double gamma13 = std::pow(B3I_WAVELENGTH_M / B1I_WAVELENGTH_M, 2.0); + const double p1_corr = obs.P[0] - SPEED_OF_LIGHT_M_S * TGD1_S; + const double expected = (gamma13 * p1_corr - obs.P[2]) / (gamma13 - 1.0); + EXPECT_NEAR(expected, corrected_pseudorange, 1.0e-6); + EXPECT_DOUBLE_EQ(0.0, iono_scale); +} + + +TEST(BdsTgdIonoTest, DualFrequencyB1IB3IIonoFreeModeAlsoRemovesTgd1) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_BDS, 6); + ASSERT_GT(sat, 0); + set_bds_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = B1I_WAVELENGTH_M; + nav.lam[sat - 1][2] = B3I_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.P[2] = 24000006.0; + obs.code[0] = CODE_L2I; + obs.code[2] = CODE_L6I; + options.ionoopt = IONOOPT_IFLC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + const double gamma13 = std::pow(B3I_WAVELENGTH_M / B1I_WAVELENGTH_M, 2.0); + const double p1_corr = obs.P[0] - SPEED_OF_LIGHT_M_S * TGD1_S; + const double expected = (gamma13 * p1_corr - obs.P[2]) / (gamma13 - 1.0); + EXPECT_NEAR(expected, corrected_pseudorange, 1.0e-6); + EXPECT_DOUBLE_EQ(0.0, iono_scale); +} + + +TEST(BdsTgdIonoTest, IonocorrPrefersBdsKlobucharForBdsSatellites) +{ + nav_t nav{}; + const double ep[6] = {2026, 7, 31, 12, 0, 0}; + const gtime_t t = epoch2time(ep); + const double pos[3] = {0.7, 2.0, 100.0}; + const double azel[2] = {0.0, 1.0}; + + const double ion_gps[8] = {0.1118e-7, -0.7451e-8, -0.5961e-7, 0.1192e-6, + 0.1167e6, -0.2294e6, -0.1311e6, 0.1049e7}; + const double ion_bds[8] = {0.2235e-7, -0.1490e-7, -0.1192e-6, 0.2384e-6, + 0.1290e6, -0.1966e6, -0.2621e6, 0.8389e6}; + for (int k = 0; k < 8; k++) + { + nav.ion_gps[k] = ion_gps[k]; + nav.ion_cmp[k] = ion_bds[k]; + } + + double ion = 0.0; + double var = 0.0; + + // GPS satellite: GPS coefficients, unscaled + ASSERT_EQ(1, ionocorr(t, &nav, satno(SYS_GPS, 1), pos, azel, IONOOPT_BRDC, &ion, &var)); + EXPECT_NEAR(ionmodel(t, nav.ion_gps, pos, azel), ion, 1.0e-9); + + // BDS satellite: BDS coefficients (B1I-referenced), expressed at GPS L1 + ASSERT_EQ(1, ionocorr(t, &nav, satno(SYS_BDS, 6), pos, azel, IONOOPT_BRDC, &ion, &var)); + const double expected_bds = ionmodel(t, nav.ion_cmp, pos, azel) * std::pow(FREQ1_BDS / FREQ1, 2.0); + EXPECT_NEAR(expected_bds, ion, 1.0e-9); + EXPECT_GT(ion, 0.0); + + // BDS satellite without BDS iono: falls back to the GPS coefficients + for (int k = 0; k < 8; k++) + { + nav.ion_cmp[k] = 0.0; + } + ASSERT_EQ(1, ionocorr(t, &nav, satno(SYS_BDS, 6), pos, azel, IONOOPT_BRDC, &ion, &var)); + EXPECT_NEAR(ionmodel(t, nav.ion_gps, pos, azel), ion, 1.0e-9); +} diff --git a/tests/unit-tests/signal-processing-blocks/pvt/gps_tgd_isc_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/gps_tgd_isc_test.cc new file mode 100644 index 000000000..d05024653 --- /dev/null +++ b/tests/unit-tests/signal-processing-blocks/pvt/gps_tgd_isc_test.cc @@ -0,0 +1,293 @@ +/*! + * \file gps_tgd_isc_test.cc + * \brief Tests for GPS TGD / inter-signal correction application in prange() + * \author Carles Fernandez-Prades, 2026. cfernandez(at)cttc.es + * + * ----------------------------------------------------------------------------- + * + * GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + * This file is part of GNSS-SDR. + * + * Copyright (C) 2010-2026 (see AUTHORS file for a list of contributors) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * ----------------------------------------------------------------------------- + */ + +#include "rtklib_pntpos.h" +#include +#include + +namespace +{ +constexpr double TGD_S = 1.0e-8; +constexpr double ISC_L1CA_S = -2.0e-9; +constexpr double ISC_L2C_S = -3.0e-9; +constexpr double ISC_L5I5_S = -4.0e-9; +constexpr double L1_WAVELENGTH_M = SPEED_OF_LIGHT_M_S / FREQ1; +constexpr double L2_WAVELENGTH_M = SPEED_OF_LIGHT_M_S / FREQ2; +constexpr double L5_WAVELENGTH_M = SPEED_OF_LIGHT_M_S / FREQ5; + +void set_gps_test_ephemeris(nav_t& nav, eph_t& eph, int sat) +{ + eph.sat = sat; + eph.tgd[0] = TGD_S; + eph.isc[0] = ISC_L1CA_S; + eph.isc[1] = ISC_L2C_S; + eph.isc[2] = ISC_L5I5_S; + nav.n = 1; + nav.eph = &eph; +} +} // namespace + + +TEST(GpsTgdIscTest, SingleFrequencyL1AppliesMinusTgd) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_GPS, 1); + ASSERT_GT(sat, 0); + set_gps_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = L1_WAVELENGTH_M; + nav.lam[sat - 1][1] = L2_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.code[0] = CODE_L1C; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // IS-GPS-200 20.3.3.3.3.2: (dtSV)_L1 = dtSV - TGD => PC = P1 - c*TGD + EXPECT_NEAR(obs.P[0] - SPEED_OF_LIGHT_M_S * TGD_S, corrected_pseudorange, 1.0e-6); + EXPECT_DOUBLE_EQ(1.0, iono_scale); +} + + +TEST(GpsTgdIscTest, SingleFrequencyL1ConvertsP1P2DcbToTgdEquivalent) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_GPS, 1); + ASSERT_GT(sat, 0); + set_gps_test_ephemeris(nav, eph, sat); + + constexpr double dcb_p1_p2_m = 3.0; + nav.cbias[sat - 1][0] = dcb_p1_p2_m; + nav.lam[sat - 1][0] = L1_WAVELENGTH_M; + nav.lam[sat - 1][1] = L2_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.code[0] = CODE_L1C; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // A raw P1-P2 DCB (here 3 m) takes precedence over the broadcast TGD and + // must be converted to its TGD equivalent: TGD = DCB / (1 - gamma12), + // so PC = P1 - DCB / (1 - gamma12), matching upstream RTKLIB + const double gamma12 = std::pow(L2_WAVELENGTH_M / L1_WAVELENGTH_M, 2.0); + const double expected = obs.P[0] - dcb_p1_p2_m / (1.0 - gamma12); + EXPECT_NEAR(expected, corrected_pseudorange, 1.0e-6); +} + + +TEST(GpsTgdIscTest, DualFrequencyL1L2AppliesMinusTgdToL1Pseudorange) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_GPS, 1); + ASSERT_GT(sat, 0); + set_gps_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = L1_WAVELENGTH_M; + nav.lam[sat - 1][1] = L2_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.P[1] = 24000005.0; + obs.code[0] = CODE_L1C; + obs.code[1] = CODE_L2S; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // With a single-frequency iono model, the L1 C/A pseudorange referenced to + // the LNAV clock takes the same correction as the L1-only case: P1 - c*TGD. + // A '+' sign here doubles the group-delay error (up to ~8 m). + EXPECT_NEAR(obs.P[0] - SPEED_OF_LIGHT_M_S * TGD_S, corrected_pseudorange, 1.0e-6); + // The measurement is L1-referenced, so the modeled iono delay applies in full + EXPECT_DOUBLE_EQ(1.0, iono_scale); +} + + +TEST(GpsTgdIscTest, SingleFrequencyL5ScalesModeledIonoByGamma15) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_GPS, 1); + ASSERT_GT(sat, 0); + set_gps_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = L1_WAVELENGTH_M; + nav.lam[sat - 1][2] = L5_WAVELENGTH_M; + obs.sat = sat; + obs.P[2] = 24000000.0; + obs.code[2] = CODE_L5X; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // The modeled L1 iono delay must be scaled by gamma15 = (f_L1/f_L5)^2 for + // an L5-only pseudorange (the PC group-delay value itself is not asserted + // here; its sign convention is exercised by the CNAV correction tests) + const double gamma15 = std::pow(L5_WAVELENGTH_M / L1_WAVELENGTH_M, 2.0); + EXPECT_NE(0.0, corrected_pseudorange); + EXPECT_DOUBLE_EQ(gamma15, iono_scale); +} + + +TEST(GpsTgdIscTest, DualFrequencyL1L5UsesIscL1caOnGammaWeightedTerm) +{ + nav_t nav{}; + eph_t eph{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_GPS, 1); + ASSERT_GT(sat, 0); + set_gps_test_ephemeris(nav, eph, sat); + + nav.lam[sat - 1][0] = L1_WAVELENGTH_M; + nav.lam[sat - 1][2] = L5_WAVELENGTH_M; + obs.sat = sat; + obs.P[0] = 24000000.0; + obs.P[2] = 24000008.0; + obs.code[0] = CODE_L1C; + obs.code[2] = CODE_L5X; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + // IS-GPS-705 20.3.3.3.1.2.2 dual-frequency (L1 C/A, L5 I5) correction: + // PC = (P5 + c*ISC_L5I5 - gamma15*(P1 + c*ISC_L1CA)) / (1 - gamma15) - c*TGD + const double gamma15 = std::pow(L5_WAVELENGTH_M / L1_WAVELENGTH_M, 2.0); + const double p1_corr = obs.P[0] + SPEED_OF_LIGHT_M_S * ISC_L1CA_S; + const double p5_corr = obs.P[2] + SPEED_OF_LIGHT_M_S * ISC_L5I5_S; + const double expected = (p5_corr - gamma15 * p1_corr) / (1.0 - gamma15) - SPEED_OF_LIGHT_M_S * TGD_S; + + EXPECT_NEAR(expected, corrected_pseudorange, 1.0e-6); + // Iono-free combination: the modeled iono delay must not be applied on top + EXPECT_DOUBLE_EQ(0.0, iono_scale); +} + + +TEST(GlonassDcbTest, SingleFrequencyL2AppliesP1P2DcbOnce) +{ + nav_t nav{}; + obsd_t obs{}; + prcopt_t options{}; + const int sat = satno(SYS_GLO, 1); + ASSERT_GT(sat, 0); + + constexpr double dcb_p1_p2_m = 3.0; + const double l1_wavelength_m = SPEED_OF_LIGHT_M_S / FREQ1_GLO; + const double l2_wavelength_m = SPEED_OF_LIGHT_M_S / FREQ2_GLO; + nav.cbias[sat - 1][0] = dcb_p1_p2_m; + nav.lam[sat - 1][0] = l1_wavelength_m; + nav.lam[sat - 1][1] = l2_wavelength_m; + obs.sat = sat; + obs.P[1] = 24000000.0; + obs.code[1] = CODE_L2C; + options.ionoopt = IONOOPT_BRDC; + + const double azel[2] = {0.0, 1.0}; + double variance = 0.0; + double iono_scale = -1.0; + const double corrected_pseudorange = prange(&obs, &nav, azel, 0, &options, &variance, &iono_scale); + + const double gamma12 = std::pow(l2_wavelength_m / l1_wavelength_m, 2.0); + const double expected = obs.P[1] - gamma12 * dcb_p1_p2_m / (1.0 - gamma12); + EXPECT_NEAR(expected, corrected_pseudorange, 1.0e-6); + EXPECT_DOUBLE_EQ(gamma12, iono_scale); +} + + +TEST(SppVarerrTest, IonoFreeCombinationAmplifiesVarianceByUpstreamFactorNine) +{ + // the ionosphere-free noise amplification is (f1^2/(f1^2-f2^2))^2 + + // (f2^2/(f1^2-f2^2))^2, approximately 8.9 for GPS L1/L2, which upstream + // RTKLIB rounds to 3^2 = 9; rescode's dual-frequency fallback applies the + // same factor, so this pins the canonical value + prcopt_t opt{}; + opt.err[1] = 0.003; + opt.err[2] = 0.003; + opt.eratio[0] = 100.0; + + obsd_t obs{}; + const double elevation_rad = 45.0 * D2R; + + opt.ionoopt = IONOOPT_OFF; + const double variance_single = varerr(&opt, &obs, elevation_rad, SYS_GPS); + ASSERT_GT(variance_single, 0.0); + + opt.ionoopt = IONOOPT_IFLC; + const double variance_iono_free = varerr(&opt, &obs, elevation_rad, SYS_GPS); + + EXPECT_DOUBLE_EQ(9.0, variance_iono_free / variance_single); +} + + +TEST(SppVarerrTest, SnrWeightingFollowsTheMeasurementSlot) +{ + // GNSS-SDR places single-band L2C/L5/E5a observations in slots 1/2 with + // slot 0 empty: the SNR term must read the slot that carries the + // pseudorange, or those satellites are weighted as if their SNR were zero + prcopt_t opt{}; + opt.err[1] = 0.003; + opt.err[2] = 0.003; + opt.err[5] = 52.0; // error_snr_max (dB-Hz) + opt.err[6] = 0.005; // error_factor_snr (m) + opt.eratio[0] = 100.0; + opt.ionoopt = IONOOPT_OFF; + const double elevation_rad = 45.0 * D2R; + const unsigned char snr_45dbhz = 180; // in 0.25 dB-Hz units + + obsd_t slot0_observation{}; + slot0_observation.P[0] = 22.0e6; + slot0_observation.SNR[0] = snr_45dbhz; + + obsd_t slot2_observation{}; // an L5/E5a-only observation lives in slot 2 + slot2_observation.P[2] = 22.0e6; + slot2_observation.SNR[2] = snr_45dbhz; + + const double variance_slot0 = varerr(&opt, &slot0_observation, elevation_rad, SYS_GPS); + const double variance_slot2 = varerr(&opt, &slot2_observation, elevation_rad, SYS_GPS); + EXPECT_DOUBLE_EQ(variance_slot0, variance_slot2); + + obsd_t zero_snr_observation{}; + zero_snr_observation.P[0] = 22.0e6; + const double variance_zero_snr = varerr(&opt, &zero_snr_observation, elevation_rad, SYS_GPS); + EXPECT_GT(variance_zero_snr, 100.0 * variance_slot2); +} diff --git a/tests/unit-tests/signal-processing-blocks/pvt/ntrip_rtcm_client_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/ntrip_rtcm_client_test.cc index 4172e40b9..9f883fbe8 100644 --- a/tests/unit-tests/signal-processing-blocks/pvt/ntrip_rtcm_client_test.cc +++ b/tests/unit-tests/signal-processing-blocks/pvt/ntrip_rtcm_client_test.cc @@ -1999,7 +1999,8 @@ TEST(NtripRtcmClientTest, AuthenticatedFragmentedStreamPublishesFixedBaseSnapsho EXPECT_GT(single_band_observation->P[0], 0.0); EXPECT_TRUE(snapshot.fresh); EXPECT_NEAR(0.0, snapshot.age_s, 1e-9); - EXPECT_GT(snapshot.observation_generation, 0U); + EXPECT_GT(snapshot.generation, 0U); + EXPECT_EQ(snapshot.generation, client.snapshot_generation()); const Ntrip_Rtcm_Snapshot slightly_future_snapshot = client.latest_snapshot(timeadd(rover_time, -0.5)); diff --git a/tests/unit-tests/signal-processing-blocks/pvt/rtklib_detslp_dop_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_detslp_dop_test.cc new file mode 100644 index 000000000..a4d5583c5 --- /dev/null +++ b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_detslp_dop_test.cc @@ -0,0 +1,126 @@ +/*! + * \file rtklib_detslp_dop_test.cc + * \brief Unit tests for the doppler / phase-difference cycle-slip detector + * \author Carles Fernandez-Prades, 2026. cfernandez(at)cttc.es + * + * ----------------------------------------------------------------------------- + * + * GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + * This file is part of GNSS-SDR. + * + * Copyright (C) 2010-2026 (see AUTHORS file for a list of contributors) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * ----------------------------------------------------------------------------- + */ + +#include "rtklib.h" +#include "rtklib_rtkcmn.h" +#include "rtklib_rtkpos.h" +#include +#include + +namespace +{ +constexpr double LAMBDA_L1 = SPEED_OF_LIGHT_M_S / 1575.42e6; +constexpr double PHASE_RATE_CYCLES_S = 1000.0; +constexpr int NUM_SATS = 4; + +struct DetslpDopFixture +{ + DetslpDopFixture() + : rtk(std::make_unique()), + nav(std::make_unique()) + { + *rtk = {}; + *nav = {}; + rtk->opt.nf = 1; + + const double ep[] = {2026, 8, 10, 0, 0, 0.0}; + t_prev = epoch2time(ep); + t_now = timeadd(t_prev, 1.0); + + for (int i = 0; i < NUM_SATS; i++) + { + ix[i] = i; + obs[i] = {}; + obs[i].sat = i + 1; + obs[i].time = t_now; + /* doppler consistent with the phase advance on every satellite */ + obs[i].L[0] = 100.0 + PHASE_RATE_CYCLES_S; + obs[i].D[0] = -PHASE_RATE_CYCLES_S; + rtk->ssat[i].ph[0][0] = 100.0; + rtk->ssat[i].pt[0][0] = t_prev; + nav->lam[i][0] = LAMBDA_L1; + } + } + std::unique_ptr rtk; + std::unique_ptr nav; + obsd_t obs[NUM_SATS]{}; + int ix[NUM_SATS]{}; + gtime_t t_prev{}; + gtime_t t_now{}; +}; +} // namespace + + +TEST(RtklibDetslpDopTest, FlagsOnlyTheSatelliteWithInconsistentPhase) +{ + DetslpDopFixture f; + /* 25-cycle slip on the last satellite: phase jumps, doppler does not */ + f.obs[NUM_SATS - 1].L[0] += 25.0; + + /* threshold 0 disables the test */ + f.rtk->opt.thresdop = 0.0; + detslp_dop(f.rtk.get(), f.obs, f.ix, NUM_SATS, 1, f.nav.get()); + for (int i = 0; i < NUM_SATS; i++) + { + EXPECT_EQ(f.rtk->ssat[i].slip[0] & 1, 0) << "sat " << i + 1; + } + + f.rtk->opt.thresdop = 1.0; + detslp_dop(f.rtk.get(), f.obs, f.ix, NUM_SATS, 1, f.nav.get()); + for (int i = 0; i < NUM_SATS - 1; i++) + { + EXPECT_EQ(f.rtk->ssat[i].slip[0] & 1, 0) << "sat " << i + 1; + } + EXPECT_EQ(f.rtk->ssat[NUM_SATS - 1].slip[0] & 1, 1); +} + + +TEST(RtklibDetslpDopTest, ImmuneToCommonReceiverClockJump) +{ + DetslpDopFixture f; + /* a receiver clock jump advances every carrier phase by the same amount; + the median common range-rate error must absorb it without false slips */ + for (int i = 0; i < NUM_SATS; i++) + { + f.obs[i].L[0] += 2000.0; + } + + f.rtk->opt.thresdop = 1.0; + detslp_dop(f.rtk.get(), f.obs, f.ix, NUM_SATS, 1, f.nav.get()); + for (int i = 0; i < NUM_SATS; i++) + { + EXPECT_EQ(f.rtk->ssat[i].slip[0] & 1, 0) << "sat " << i + 1; + } +} + + +TEST(RtklibDetslpDopTest, DetectsSlipOnTopOfClockJump) +{ + DetslpDopFixture f; + for (int i = 0; i < NUM_SATS; i++) + { + f.obs[i].L[0] += 2000.0; + } + f.obs[0].L[0] += 25.0; /* slip on the first satellite, on top of the jump */ + + f.rtk->opt.thresdop = 1.0; + detslp_dop(f.rtk.get(), f.obs, f.ix, NUM_SATS, 1, f.nav.get()); + EXPECT_EQ(f.rtk->ssat[0].slip[0] & 1, 1); + for (int i = 1; i < NUM_SATS; i++) + { + EXPECT_EQ(f.rtk->ssat[i].slip[0] & 1, 0) << "sat " << i + 1; + } +} diff --git a/tests/unit-tests/signal-processing-blocks/pvt/rtklib_fixed_base_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_fixed_base_test.cc index 9a00fd8d6..87b050342 100644 --- a/tests/unit-tests/signal-processing-blocks/pvt/rtklib_fixed_base_test.cc +++ b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_fixed_base_test.cc @@ -1437,7 +1437,7 @@ TEST_F(RtklibFixedBaseTest, BaseStreamEphemerisSubstitutesForUndecodedRoverEphem satellites, false, true); solver->gps_ephemeris_map.erase(static_cast(undecoded_prn)); solver->gps_cnav_ephemeris_map.erase(static_cast(undecoded_prn)); - epoch.base_snapshot.gps_ephemerides.push_back( + epoch.base_snapshot.ephemerides.push_back( eph_to_rtklib(make_relative_ephemeris(undecoded_prn))); SCOPED_TRACE(::testing::Message() << "epoch=" << epoch_index); diff --git a/tests/unit-tests/signal-processing-blocks/pvt/rtklib_pvt_ntrip_configuration_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_pvt_ntrip_configuration_test.cc index 3a709d0b7..b1cc42a47 100644 --- a/tests/unit-tests/signal-processing-blocks/pvt/rtklib_pvt_ntrip_configuration_test.cc +++ b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_pvt_ntrip_configuration_test.cc @@ -17,6 +17,7 @@ #include "in_memory_configuration.h" #include "rtklib_pvt.h" #include +#include #include #include #include @@ -354,3 +355,38 @@ TEST_F(RtklibPvtNtripConfigurationTest, RejectsInvalidPasswordConfigurationBefor duplicate_password_source->set_property("PVT.ntrip_password_env", "UNUSED_NTRIP_PASSWORD"); expect_invalid_configuration(*duplicate_password_source, "mutually exclusive"); } + + +TEST_F(RtklibPvtNtripConfigurationTest, ResolvesThePasswordFromTheNamedEnvironmentVariable) +{ + using namespace rtklib_pvt_ntrip_configuration_test_detail; + ASSERT_EQ(0, setenv("GNSS_SDR_TEST_NTRIP_PASSWORD", "secret-from-env", 1)); + + // Without a username, a resolved password must trigger the + // username-is-required rule: the throw proves the environment value + // actually materialized as the password + std::unique_ptr env_password_no_username = make_valid_ntrip_configuration(); + env_password_no_username->set_property("PVT.ntrip_password_env", "GNSS_SDR_TEST_NTRIP_PASSWORD"); + expect_invalid_configuration(*env_password_no_username, "username is required"); + + std::unique_ptr env_password = make_valid_ntrip_configuration(); + env_password->set_property("PVT.ntrip_username", "user"); + env_password->set_property("PVT.ntrip_password_env", "GNSS_SDR_TEST_NTRIP_PASSWORD"); + std::unique_ptr adapter; + EXPECT_NO_THROW(adapter.reset(new Rtklib_Pvt(env_password.get(), ROLE, 2, 0))); + ASSERT_NE(nullptr, adapter); + + ASSERT_EQ(0, unsetenv("GNSS_SDR_TEST_NTRIP_PASSWORD")); +} + + +TEST_F(RtklibPvtNtripConfigurationTest, RejectsAPasswordEnvironmentVariableThatIsNotSet) +{ + using namespace rtklib_pvt_ntrip_configuration_test_detail; + ASSERT_EQ(0, unsetenv("GNSS_SDR_TEST_NTRIP_PASSWORD_UNSET")); + + std::unique_ptr unset_variable = make_valid_ntrip_configuration(); + unset_variable->set_property("PVT.ntrip_username", "user"); + unset_variable->set_property("PVT.ntrip_password_env", "GNSS_SDR_TEST_NTRIP_PASSWORD_UNSET"); + expect_invalid_configuration(*unset_variable, "not set"); +} diff --git a/tests/unit-tests/signal-processing-blocks/pvt/rtklib_udpos_test.cc b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_udpos_test.cc new file mode 100644 index 000000000..f413d07c1 --- /dev/null +++ b/tests/unit-tests/signal-processing-blocks/pvt/rtklib_udpos_test.cc @@ -0,0 +1,237 @@ +/*! + * \file rtklib_udpos_test.cc + * \brief This file implements tests for the temporal update of the position, + * velocity and acceleration states of the RTK filter. + * \author Carles Fernandez-Prades, 2026. cfernandez(at)cttc.es + * + * ----------------------------------------------------------------------------- + * + * GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + * This file is part of GNSS-SDR. + * + * Copyright (C) 2010-2026 (see AUTHORS file for a list of contributors) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * ----------------------------------------------------------------------------- + */ + +#include "rtklib_rtkpos.h" +#include "rtklib_rtksvr.h" +#include +#include +#include + +namespace rtklib_udpos_test_detail +{ +// a plausible ECEF position, so that the filter is not reinitialized +constexpr double POSITION_ECEF_M[3] = {4600000.0, 700000.0, 4370000.0}; +constexpr double VELOCITY_ECEF_MPS[3] = {3.0, -2.0, 1.0}; +constexpr double ACCELERATION_ECEF_MPS2[3] = {0.4, -0.3, 0.2}; + + +prcopt_t dynamic_options() +{ + prcopt_t options = PRCOPT_DEFAULT; + options.mode = PMODE_KINEMA; + options.nf = 1; + options.navsys = SYS_GPS; + options.dynamics = 1; + options.armaxposvar = 0.25; + return options; +} + + +class Rtk_State +{ +public: + explicit Rtk_State(const prcopt_t& options) + { + rtkinit(&d_rtk, &options); + } + + ~Rtk_State() + { + rtkfree(&d_rtk); + } + + Rtk_State(const Rtk_State&) = delete; + Rtk_State& operator=(const Rtk_State&) = delete; + + //! Puts the filter in a converged state moving with a constant acceleration + void set_dynamic_state(double position_variance_m2) + { + for (int i = 0; i < 3; i++) + { + d_rtk.x[i] = POSITION_ECEF_M[i]; + d_rtk.x[i + 3] = VELOCITY_ECEF_MPS[i]; + d_rtk.x[i + 6] = ACCELERATION_ECEF_MPS2[i]; + d_rtk.sol.rr[i] = POSITION_ECEF_M[i]; + d_rtk.sol.rr[i + 3] = VELOCITY_ECEF_MPS[i]; + } + for (int i = 0; i < 9; i++) + { + d_rtk.P[i + i * d_rtk.nx] = position_variance_m2; + } + } + + rtk_t& rtk() { return d_rtk; } + +private: + rtk_t d_rtk{}; +}; +} // namespace rtklib_udpos_test_detail + + +TEST(RtklibUdposTest, AccelerationDrivesPositionOnceTheFilterHasConverged) +{ + using namespace rtklib_udpos_test_detail; + Rtk_State state(dynamic_options()); + state.set_dynamic_state(0.01); // well below armaxposvar + + const double tt = 0.5; + udpos(&state.rtk(), tt); + + for (int i = 0; i < 3; i++) + { + const double expected_position = POSITION_ECEF_M[i] + + VELOCITY_ECEF_MPS[i] * tt + + 0.5 * ACCELERATION_ECEF_MPS2[i] * tt * tt; + const double expected_velocity = VELOCITY_ECEF_MPS[i] + ACCELERATION_ECEF_MPS2[i] * tt; + EXPECT_NEAR(expected_position, state.rtk().x[i], 1e-9) << "axis " << i; + EXPECT_NEAR(expected_velocity, state.rtk().x[i + 3], 1e-9) << "axis " << i; + } +} + + +TEST(RtklibUdposTest, AccelerationIsKeptOutOfPositionWhileTheVarianceIsLarge) +{ + using namespace rtklib_udpos_test_detail; + prcopt_t options = dynamic_options(); + Rtk_State state(options); + state.set_dynamic_state(10.0 * options.armaxposvar); + + const double tt = 0.5; + udpos(&state.rtk(), tt); + + for (int i = 0; i < 3; i++) + { + // velocity still propagates, the acceleration term does not + const double expected_position = POSITION_ECEF_M[i] + VELOCITY_ECEF_MPS[i] * tt; + EXPECT_NEAR(expected_position, state.rtk().x[i], 1e-9) << "axis " << i; + } +} + + +TEST(RtklibUdposTest, TheAccelerationGateCanBeDisabled) +{ + using namespace rtklib_udpos_test_detail; + prcopt_t options = dynamic_options(); + options.armaxposvar = 0.0; // no gate + Rtk_State state(options); + // large, but below the variance that makes the filter reset the position + state.set_dynamic_state(100.0); + + const double tt = 0.5; + udpos(&state.rtk(), tt); + + for (int i = 0; i < 3; i++) + { + const double expected_position = POSITION_ECEF_M[i] + + VELOCITY_ECEF_MPS[i] * tt + + 0.5 * ACCELERATION_ECEF_MPS2[i] * tt * tt; + EXPECT_NEAR(expected_position, state.rtk().x[i], 1e-9) << "axis " << i; + } +} + + +TEST(RtklibUdposTest, AccelerationProcessNoiseGrowsWithTheElapsedTime) +{ + using namespace rtklib_udpos_test_detail; + const prcopt_t options = dynamic_options(); + + const auto acceleration_variance_after = [&options](double tt) { + Rtk_State state(options); + state.set_dynamic_state(0.01); + const double before = state.rtk().P[6 + 6 * state.rtk().nx]; + udpos(&state.rtk(), tt); + return state.rtk().P[6 + 6 * state.rtk().nx] - before; + }; + + const double growth_half_second = acceleration_variance_after(0.5); + const double growth_one_second = acceleration_variance_after(1.0); + + EXPECT_GT(growth_half_second, 0.0); + EXPECT_NEAR(2.0, growth_one_second / growth_half_second, 1e-6); +} + + +TEST(RtklibUdposTest, ProcessNoiseStaysPositiveWhenTimeGoesBackwards) +{ + using namespace rtklib_udpos_test_detail; + const prcopt_t options = dynamic_options(); + + Rtk_State forward(options); + forward.set_dynamic_state(0.01); + const double before = forward.rtk().P[6 + 6 * forward.rtk().nx]; + udpos(&forward.rtk(), 1.0); + const double forward_growth = forward.rtk().P[6 + 6 * forward.rtk().nx] - before; + + Rtk_State backward(options); + backward.set_dynamic_state(0.01); + udpos(&backward.rtk(), -1.0); + const double backward_growth = backward.rtk().P[6 + 6 * backward.rtk().nx] - before; + + // a negative time step must not remove process noise from the filter. The + // two are not bit-identical because the noise is rotated to ECEF at the + // propagated position, which differs between the two directions + EXPECT_GT(backward_growth, 0.0); + EXPECT_NEAR(1.0, backward_growth / forward_growth, 1e-5); +} + + +TEST(RtklibUdposTest, IonosphericProcessNoiseStaysPositiveWhenTimeGoesBackwards) +{ + using namespace rtklib_udpos_test_detail; + prcopt_t options = dynamic_options(); + options.ionoopt = IONOOPT_EST; + Rtk_State state(options); + + // an already initialized ionospheric state, so that udion() adds process + // noise to it instead of initializing it + const int satellite = 1; + const int index = II_RTK(satellite, &state.rtk().opt); + state.rtk().x[index] = 1.0; + state.rtk().P[index + index * state.rtk().nx] = 0.1; + state.rtk().ssat[satellite - 1].azel[1] = 45.0 * D2R; + + const double baseline_m = 10000.0; + const double variance_before = state.rtk().P[index + index * state.rtk().nx]; + udion(&state.rtk(), -1.0, baseline_m, &satellite, 1); + + // a negative time step must not remove process noise from the filter + EXPECT_GT(state.rtk().P[index + index * state.rtk().nx], variance_before); +} + + +TEST(RtklibUdposTest, AnImplausiblePositionStateIsReinitialized) +{ + using namespace rtklib_udpos_test_detail; + Rtk_State state(dynamic_options()); + state.set_dynamic_state(0.01); + + // a leftover state that is not a position on the Earth: the filter would + // never converge from here + for (int i = 0; i < 3; i++) + { + state.rtk().x[i] = 1000.0; + } + + udpos(&state.rtk(), 1.0); + + for (int i = 0; i < 3; i++) + { + // reinitialized from the single-point solution, then propagated + EXPECT_GT(std::fabs(state.rtk().x[i]), 1000.0) << "axis " << i; + EXPECT_NEAR(POSITION_ECEF_M[i], state.rtk().x[i], 10.0) << "axis " << i; + } +}