diff --git a/Project.toml b/Project.toml index 17fd681..8fee471 100755 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ authors = ["Soeren Schoenbrod ", "Michael AstroTime = "c61b5328-d09d-5e37-a9a8-0eb41c39009c" CoordinateTransformations = "150eb455-5306-5404-9cee-2592286d6298" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Dictionaries = "85a47980-9c8c-11e8-2b9f-f7ca1fa99fb4" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" GNSSDecoder = "bf6b1ec8-12ad-4501-972c-3dec0d675c2b" GNSSSignals = "52c80523-2a4e-5c38-8979-05588f836870" @@ -14,7 +15,6 @@ Geodesy = "0ef565a4-170c-5f04-8de2-149903a85f3d" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" LsqFit = "2fda8390-95c7-5789-9bda-21331edee243" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" -Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [weakdeps] @@ -24,23 +24,23 @@ Tracking = "10b2438b-ffd4-5096-aa58-44041d5c8f3b" PositionVelocityTimeTrackingExt = "Tracking" [compat] -Acquisition = "2" +Acquisition = "2.6" Aqua = "0.8" AstroTime = "0.7" CodecZlib = "0.7" CoordinateTransformations = "0.6" Dates = "1" +Dictionaries = "0.4" DocStringExtensions = "0.6, 0.7, 0.8, 0.9" Downloads = "1" -GNSSDecoder = "2" -GNSSSignals = "2" +GNSSDecoder = "3.6" +GNSSSignals = "3.3" Geodesy = "0.5, 1.0" LinearAlgebra = "1" LsqFit = "0.12, 0.13, 0.14, 0.15, 0.16" StaticArrays = "1" -Statistics = "1" Test = "1" -Tracking = "2" +Tracking = "2.2" Unitful = "1" julia = "1.10" diff --git a/benchmark/fixtures.jl b/benchmark/fixtures.jl index 593c124..e85cd83 100644 --- a/benchmark/fixtures.jl +++ b/benchmark/fixtures.jl @@ -1,1420 +1,19 @@ -# Real-world satellite states extracted from test/pvt.jl. -# These are used as inputs to `calc_pvt` benchmarks so that timings reflect -# realistic decoder data and a converging least-squares geometry. +# Real-world satellite states used as inputs to `calc_pvt` benchmarks so that +# timings reflect realistic decoder data and a converging least-squares geometry. +# The fixture functions live in test/fixtures.jl, shared with the test suite, so +# the benchmark inputs cannot drift from the test fixtures (that file also carries +# the GNSSDecoder major-version shim AirspeedVelocity needs to run this script +# against the base revision). using GNSSDecoder using GNSSSignals using PositionVelocityTime using Unitful: Hz -# AirspeedVelocity runs this script against both the PR and the base revision, -# which may resolve either major version of GNSSSignals/GNSSDecoder. v2 renamed -# the GPS L1 C/A types (GPSL1* -> GPSL1CA*), so pick whichever this version -# provides. -const GPSL1CASystem = isdefined(GNSSSignals, :GPSL1CA) ? GNSSSignals.GPSL1CA : GNSSSignals.GPSL1 -const GPSDataType = isdefined(GNSSDecoder, :GPSL1CAData) ? GNSSDecoder.GPSL1CAData : GNSSDecoder.GPSL1Data -const GPSConstantsType = - isdefined(GNSSDecoder, :GPSL1CAConstants) ? GNSSDecoder.GPSL1CAConstants : - GNSSDecoder.GPSL1Constants -const GPSCacheType = - isdefined(GNSSDecoder, :GPSL1CACache) ? GNSSDecoder.GPSL1CACache : GNSSDecoder.GPSL1Cache +include(joinpath(@__DIR__, "..", "test", "fixtures.jl")) -# v2's soft-symbol decoder dropped the packed-bit `raw_buffer`/`buffer` fields and -# `num_bits_buffered` from GNSSDecoderState. Construct positionally for whichever -# layout the resolved GNSSDecoder version exposes; the bit buffers are irrelevant -# to `calc_pvt`, so they default to zero on the older layout. -function decoder_state(prn, raw_data, data, constants, cache, num_bits_after, is_shifted) - if :raw_buffer in fieldnames(GNSSDecoder.GNSSDecoderState) - return GNSSDecoderState( - prn, - zero(UInt128), - zero(UInt128), - raw_data, - data, - constants, - cache, - 0, - num_bits_after, - is_shifted, - ) - else - return GNSSDecoderState(prn, raw_data, data, constants, cache, num_bits_after, is_shifted) - end -end +"5 Galileo E1B satellites over Aachen, 2021-05-31 (from test/fixtures.jl)." +make_galileo_states() = galileo_e1b_states(0.0Hz) -"5 Galileo E1B satellites over Aachen, 2021-05-31 (from test/pvt.jl)." -function make_galileo_states() - galileo_e1b = GalileoE1B() - states = [ - SatelliteState(; - decoder = decoder_state( - 2, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 1.885093958164237, - e = 0.0002336719771847129, - sqrt_A = 5440.601516723633, - Ω_0 = 0.43151849653459906, - i_0 = 0.9782668574498574, - ω = 0.18081747042485646, - i_dot = -3.6965825489294766e-10, - Ω_dot = -5.353794435599309e-9, - Δn = 3.053341469642328e-9, - C_uc = 1.9334256649017334e-6, - C_us = 8.616596460342407e-6, - C_rc = 163.65625, - C_rs = 40.46875, - C_ic = -4.284083843231201e-8, - C_is = 7.264316082000732e-8, - t_0c = 132000.0, - a_f0 = 0.00022286397870630026, - a_f1 = 2.5721647034515627e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = -2.0954757928848267e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 1.885093958164237, - e = 0.0002336719771847129, - sqrt_A = 5440.601516723633, - Ω_0 = 0.43151849653459906, - i_0 = 0.9782668574498574, - ω = 0.18081747042485646, - i_dot = -3.6965825489294766e-10, - Ω_dot = -5.353794435599309e-9, - Δn = 3.053341469642328e-9, - C_uc = 1.9334256649017334e-6, - C_us = 8.616596460342407e-6, - C_rc = 163.65625, - C_rs = 40.46875, - C_ic = -4.284083843231201e-8, - C_is = 7.264316082000732e-8, - t_0c = 132000.0, - a_f0 = 0.00022286397870630026, - a_f1 = 2.5721647034515627e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = -2.0954757928848267e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1549, - false, - ), - system = galileo_e1b, - carrier_doppler = 1617.3312825078192Hz, - code_phase = 2216.5793761534956, - carrier_phase = -1.461823180908076, - ), - SatelliteState(; - decoder = decoder_state( - 4, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 128400.0, - M_0 = 1.1883716376830096, - e = 0.0003440612927079201, - sqrt_A = 5440.625782012939, - Ω_0 = -1.666444269041331, - i_0 = 0.9544851798532423, - ω = -1.401875745066068, - i_dot = 4.318037006430664e-10, - Ω_dot = -5.442726711414135e-9, - Δn = 3.016554223020131e-9, - C_uc = -2.2649765014648438e-6, - C_us = 9.763985872268677e-6, - C_rc = 128.46875, - C_rs = -51.40625, - C_ic = 5.029141902923584e-8, - C_is = -2.2351741790771484e-8, - t_0c = 128400.0, - a_f0 = -0.0007824758649803698, - a_f1 = -8.01492205937393e-12, - a_f2 = 0.0, - IOD_nav1 = 0x0000000000000056, - IOD_nav2 = 0x0000000000000056, - IOD_nav3 = 0x0000000000000056, - IOD_nav4 = 0x0000000000000056, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -3.026798367500305e-9, - broadcast_group_delay_e1_e5b = -3.4924596548080444e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 128400.0, - M_0 = 1.1883716376830096, - e = 0.0003440612927079201, - sqrt_A = 5440.625782012939, - Ω_0 = -1.666444269041331, - i_0 = 0.9544851798532423, - ω = -1.401875745066068, - i_dot = 4.318037006430664e-10, - Ω_dot = -5.442726711414135e-9, - Δn = 3.016554223020131e-9, - C_uc = -2.2649765014648438e-6, - C_us = 9.763985872268677e-6, - C_rc = 128.46875, - C_rs = -51.40625, - C_ic = 5.029141902923584e-8, - C_is = -2.2351741790771484e-8, - t_0c = 128400.0, - a_f0 = -0.0007824758649803698, - a_f1 = -8.01492205937393e-12, - a_f2 = 0.0, - IOD_nav1 = 0x0000000000000056, - IOD_nav2 = 0x0000000000000056, - IOD_nav3 = 0x0000000000000056, - IOD_nav4 = 0x0000000000000056, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -3.026798367500305e-9, - broadcast_group_delay_e1_e5b = -3.4924596548080444e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1546, - true, - ), - system = galileo_e1b, - carrier_doppler = 4055.9318556579988Hz, - code_phase = 48.75149191469789, - carrier_phase = -2.3877702498883373, - ), - SatelliteState(; - decoder = decoder_state( - 11, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.7593379982069668, - e = 0.0005043556448072195, - sqrt_A = 5440.614900588989, - Ω_0 = 2.515118401165434, - i_0 = 0.9919634586882758, - ω = 0.7810709950849496, - i_dot = -1.821504444400032e-11, - Ω_dot = -5.659164298336962e-9, - Δn = 2.699398155054008e-9, - C_uc = 6.221234798431396e-7, - C_us = 2.125278115272522e-6, - C_rc = 311.3125, - C_rs = 12.25, - C_ic = -2.9802322387695312e-8, - C_is = 5.4016709327697754e-8, - t_0c = 132000.0, - a_f0 = 0.005911215674132109, - a_f1 = -4.936850928061176e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.3271346688270569e-8, - broadcast_group_delay_e1_e5b = -1.4435499906539917e-8, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.7593379982069668, - e = 0.0005043556448072195, - sqrt_A = 5440.614900588989, - Ω_0 = 2.515118401165434, - i_0 = 0.9919634586882758, - ω = 0.7810709950849496, - i_dot = -1.821504444400032e-11, - Ω_dot = -5.659164298336962e-9, - Δn = 2.699398155054008e-9, - C_uc = 6.221234798431396e-7, - C_us = 2.125278115272522e-6, - C_rc = 311.3125, - C_rs = 12.25, - C_ic = -2.9802322387695312e-8, - C_is = 5.4016709327697754e-8, - t_0c = 132000.0, - a_f0 = 0.005911215674132109, - a_f1 = -4.936850928061176e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.3271346688270569e-8, - broadcast_group_delay_e1_e5b = -1.4435499906539917e-8, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1551, - false, - ), - system = galileo_e1b, - carrier_doppler = 209.7024609093128Hz, - code_phase = 69.54389556899758, - carrier_phase = -1.4138935647217596, - ), - SatelliteState(; - decoder = decoder_state( - 25, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = -2.0355462626837646, - e = 0.00014415336772799492, - sqrt_A = 5440.611436843872, - Ω_0 = 0.42869178039052547, - i_0 = 0.9778386598651378, - ω = -1.4136735049701232, - i_dot = -3.982308736286344e-10, - Ω_dot = -5.379866950195624e-9, - Δn = 3.036555056135112e-9, - C_uc = 1.9241124391555786e-6, - C_us = 8.305534720420837e-6, - C_rc = 172.59375, - C_rs = 40.625, - C_ic = -7.450580596923828e-8, - C_is = 3.3527612686157227e-8, - t_0c = 132000.0, - a_f0 = -0.000535494415089488, - a_f1 = -1.2221335055073723e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 3.4924596548080444e-9, - broadcast_group_delay_e1_e5b = 3.958120942115784e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = -2.0355462626837646, - e = 0.00014415336772799492, - sqrt_A = 5440.611436843872, - Ω_0 = 0.42869178039052547, - i_0 = 0.9778386598651378, - ω = -1.4136735049701232, - i_dot = -3.982308736286344e-10, - Ω_dot = -5.379866950195624e-9, - Δn = 3.036555056135112e-9, - C_uc = 1.9241124391555786e-6, - C_us = 8.305534720420837e-6, - C_rc = 172.59375, - C_rs = 40.625, - C_ic = -7.450580596923828e-8, - C_is = 3.3527612686157227e-8, - t_0c = 132000.0, - a_f0 = -0.000535494415089488, - a_f1 = -1.2221335055073723e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 3.4924596548080444e-9, - broadcast_group_delay_e1_e5b = 3.958120942115784e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1548, - true, - ), - system = galileo_e1b, - carrier_doppler = -974.6289079820336Hz, - code_phase = 1701.8894620076721, - carrier_phase = -1.721713905186919, - ), - SatelliteState(; - decoder = decoder_state( - 30, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.43031807277213097, - e = 0.00031052413396537304, - sqrt_A = 5440.595668792725, - Ω_0 = 0.43339143836728705, - i_0 = 0.9757531121479899, - ω = 0.869054289656611, - i_dot = -3.9465929628667357e-10, - Ω_dot = -5.42129724736237e-9, - Δn = 3.102986394695584e-9, - C_uc = 2.089887857437134e-6, - C_us = 8.553266525268555e-6, - C_rc = 162.5, - C_rs = 45.375, - C_ic = 8.754432201385498e-8, - C_is = 8.195638656616211e-8, - t_0c = 132000.0, - a_f0 = 0.002901351428590715, - a_f1 = -3.0667024475405924e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = 1.6298145055770874e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.43031807277213097, - e = 0.00031052413396537304, - sqrt_A = 5440.595668792725, - Ω_0 = 0.43339143836728705, - i_0 = 0.9757531121479899, - ω = 0.869054289656611, - i_dot = -3.9465929628667357e-10, - Ω_dot = -5.42129724736237e-9, - Δn = 3.102986394695584e-9, - C_uc = 2.089887857437134e-6, - C_us = 8.553266525268555e-6, - C_rc = 162.5, - C_rs = 45.375, - C_ic = 8.754432201385498e-8, - C_is = 8.195638656616211e-8, - t_0c = 132000.0, - a_f0 = 0.002901351428590715, - a_f1 = -3.0667024475405924e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = 1.6298145055770874e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1547, - true, - ), - system = galileo_e1b, - carrier_doppler = 4089.415808665647Hz, - code_phase = 4015.495436832823, - carrier_phase = -2.8554107708683047, - ), - ] - return states -end - -"9 GPS L1 satellites over Aachen, 2021-05-31 (from test/pvt.jl)." -function make_gps_states() - gpsl1 = GPSL1CASystem() - states = [ - SatelliteState(; - decoder = decoder_state( - 7, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001000110", - l2pcode = false, - T_GD = -1.1175870895385742e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 1.0231815394945443e-11, - a_f0 = 0.0001659449189901352, - IODE_Sub_2 = "01000110", - C_rs = 14.90625, - Δn = 4.9980653323400095e-9, - M_0 = -1.5018856847786306, - C_uc = 8.065253496170044e-7, - e = 0.014979799510911107, - C_us = 6.051734089851379e-6, - sqrt_A = 5153.747692108154, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 3.110617399215698e-7, - Ω_0 = -0.42148546787582664, - C_is = -5.21540641784668e-8, - i_0 = 0.9517858648559379, - C_rc = 250.375, - ω = -2.331033772387461, - Ω_dot = -8.050692486513945e-9, - IODE_Sub_3 = "01000110", - i_dot = 1.657211886669833e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001000110", - l2pcode = false, - T_GD = -1.1175870895385742e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 1.0231815394945443e-11, - a_f0 = 0.0001659449189901352, - IODE_Sub_2 = "01000110", - C_rs = 14.90625, - Δn = 4.9980653323400095e-9, - M_0 = -1.5018856847786306, - C_uc = 8.065253496170044e-7, - e = 0.014979799510911107, - C_us = 6.051734089851379e-6, - sqrt_A = 5153.747692108154, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 3.110617399215698e-7, - Ω_0 = -0.42148546787582664, - C_is = -5.21540641784668e-8, - i_0 = 0.9517858648559379, - C_rc = 250.375, - ω = -2.331033772387461, - Ω_dot = -8.050692486513945e-9, - IODE_Sub_3 = "01000110", - i_dot = 1.657211886669833e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 2669.8440799388595Hz, - code_phase = 4876.431542382193, - carrier_phase = 0.09402551301430394, - ), - SatelliteState(; - decoder = decoder_state( - 8, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001100111", - l2pcode = false, - T_GD = 5.122274160385132e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.4779288903810084e-12, - a_f0 = -2.3324042558670044e-5, - IODE_Sub_2 = "01100111", - C_rs = 84.21875, - Δn = 4.435899058715372e-9, - M_0 = 1.3315461632138303, - C_uc = 4.639849066734314e-6, - e = 0.006349898059852421, - C_us = 6.3907355070114136e-6, - sqrt_A = 5153.606374740601, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 6.891787052154541e-8, - Ω_0 = 1.6470055929195884, - C_is = 7.450580596923828e-8, - i_0 = 0.9671090437449873, - C_rc = 257.125, - ω = 0.05450158504406715, - Ω_dot = -8.087122575401946e-9, - IODE_Sub_3 = "01100111", - i_dot = -1.353627812603161e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001100111", - l2pcode = false, - T_GD = 5.122274160385132e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.4779288903810084e-12, - a_f0 = -2.3324042558670044e-5, - IODE_Sub_2 = "01100111", - C_rs = 84.21875, - Δn = 4.435899058715372e-9, - M_0 = 1.3315461632138303, - C_uc = 4.639849066734314e-6, - e = 0.006349898059852421, - C_us = 6.3907355070114136e-6, - sqrt_A = 5153.606374740601, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 6.891787052154541e-8, - Ω_0 = 1.6470055929195884, - C_is = 7.450580596923828e-8, - i_0 = 0.9671090437449873, - C_rc = 257.125, - ω = 0.05450158504406715, - Ω_dot = -8.087122575401946e-9, - IODE_Sub_3 = "01100111", - i_dot = -1.353627812603161e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 4704.3549972665805Hz, - code_phase = 8455.107739656896, - carrier_phase = 2.7614518715603946, - ), - SatelliteState(; - decoder = decoder_state( - 10, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000101000", - l2pcode = false, - T_GD = 2.3283064365386963e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -7.617018127348274e-12, - a_f0 = -0.0001329910010099411, - IODE_Sub_2 = "00101000", - C_rs = -89.25, - Δn = 4.016595878769169e-9, - M_0 = -2.941058160546095, - C_uc = -4.645437002182007e-6, - e = 0.006653358926996589, - C_us = 7.499009370803833e-6, - sqrt_A = 5153.6786251068115, - t_0e = 136800, - fit_interval = false, - AODO = 20, - C_ic = -3.5390257835388184e-8, - Ω_0 = -2.5268399079275254, - C_is = -6.146728992462158e-8, - i_0 = 0.969412731565689, - C_rc = 239.09375, - ω = -2.5641372296478258, - Ω_dot = -7.765323456891273e-9, - IODE_Sub_3 = "00101000", - i_dot = 3.893019302737323e-11, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000101000", - l2pcode = false, - T_GD = 2.3283064365386963e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -7.617018127348274e-12, - a_f0 = -0.0001329910010099411, - IODE_Sub_2 = "00101000", - C_rs = -89.25, - Δn = 4.016595878769169e-9, - M_0 = -2.941058160546095, - C_uc = -4.645437002182007e-6, - e = 0.006653358926996589, - C_us = 7.499009370803833e-6, - sqrt_A = 5153.6786251068115, - t_0e = 136800, - fit_interval = false, - AODO = 20, - C_ic = -3.5390257835388184e-8, - Ω_0 = -2.5268399079275254, - C_is = -6.146728992462158e-8, - i_0 = 0.969412731565689, - C_rc = 239.09375, - ω = -2.5641372296478258, - Ω_dot = -7.765323456891273e-9, - IODE_Sub_3 = "00101000", - i_dot = 3.893019302737323e-11, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = 4603.179391134832Hz, - code_phase = 10510.15670303955, - carrier_phase = -0.7447786034769108, - ), - SatelliteState(; - decoder = decoder_state( - 15, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001011001", - l2pcode = false, - T_GD = -1.0710209608078003e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 2.6147972675971687e-12, - a_f0 = -0.00014561135321855545, - IODE_Sub_2 = "01011001", - C_rs = -30.5625, - Δn = 5.182001565450993e-9, - M_0 = 0.38753804422936994, - C_uc = -1.471489667892456e-6, - e = 0.013163934461772442, - C_us = 9.061768651008606e-6, - sqrt_A = 5153.620079040527, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.695007085800171e-7, - Ω_0 = -1.5933596086804434, - C_is = 1.6205012798309326e-7, - i_0 = 0.9286304727294836, - C_rc = 190.6875, - ω = 0.98557755063243, - Ω_dot = -8.41785063726752e-9, - IODE_Sub_3 = "01011001", - i_dot = 3.010839699272994e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001011001", - l2pcode = false, - T_GD = -1.0710209608078003e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 2.6147972675971687e-12, - a_f0 = -0.00014561135321855545, - IODE_Sub_2 = "01011001", - C_rs = -30.5625, - Δn = 5.182001565450993e-9, - M_0 = 0.38753804422936994, - C_uc = -1.471489667892456e-6, - e = 0.013163934461772442, - C_us = 9.061768651008606e-6, - sqrt_A = 5153.620079040527, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.695007085800171e-7, - Ω_0 = -1.5933596086804434, - C_is = 1.6205012798309326e-7, - i_0 = 0.9286304727294836, - C_rc = 190.6875, - ω = 0.98557755063243, - Ω_dot = -8.41785063726752e-9, - IODE_Sub_3 = "01011001", - i_dot = 3.010839699272994e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 3144.174219887768Hz, - code_phase = 3618.5099300503684, - carrier_phase = -0.22375187424152987, - ), - SatelliteState(; - decoder = decoder_state( - 16, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000100010", - l2pcode = false, - T_GD = -1.0244548320770264e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.0254023992456496e-12, - a_f0 = -0.0003338884562253952, - IODE_Sub_2 = "00100010", - C_rs = 37.0, - Δn = 4.26732060817482e-9, - M_0 = 2.20050848506247, - C_uc = 1.8272548913955688e-6, - e = 0.012199128163047135, - C_us = 9.119510650634766e-6, - sqrt_A = 5153.70516204834, - t_0e = 136800, - fit_interval = false, - AODO = 22, - C_ic = -2.3655593395233154e-7, - Ω_0 = 0.7074087615001045, - C_is = -1.30385160446167e-7, - i_0 = 0.9738089978878275, - C_rc = 212.75, - ω = 0.6779943621465904, - Ω_dot = -7.692463279115272e-9, - IODE_Sub_3 = "00100010", - i_dot = -2.95012288445966e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000100010", - l2pcode = false, - T_GD = -1.0244548320770264e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.0254023992456496e-12, - a_f0 = -0.0003338884562253952, - IODE_Sub_2 = "00100010", - C_rs = 37.0, - Δn = 4.26732060817482e-9, - M_0 = 2.20050848506247, - C_uc = 1.8272548913955688e-6, - e = 0.012199128163047135, - C_us = 9.119510650634766e-6, - sqrt_A = 5153.70516204834, - t_0e = 136800, - fit_interval = false, - AODO = 22, - C_ic = -2.3655593395233154e-7, - Ω_0 = 0.7074087615001045, - C_is = -1.30385160446167e-7, - i_0 = 0.9738089978878275, - C_rc = 212.75, - ω = 0.6779943621465904, - Ω_dot = -7.692463279115272e-9, - IODE_Sub_3 = "00100010", - i_dot = -2.95012288445966e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 595.7926881306387Hz, - code_phase = 17600.66651489137, - carrier_phase = 2.59152430602131, - ), - SatelliteState(; - decoder = decoder_state( - 18, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "1111100001", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.3642420526593924e-12, - a_f0 = 0.0003486964851617813, - IODE_Sub_2 = "11100001", - C_rs = -15.3125, - Δn = 4.35303846438188e-9, - M_0 = -0.6041391930737793, - C_uc = -8.717179298400879e-7, - e = 0.0014569575432687998, - C_us = 3.339722752571106e-6, - sqrt_A = 5153.662895202637, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 2.60770320892334e-8, - Ω_0 = 2.733796625968394, - C_is = -4.284083843231201e-8, - i_0 = 0.9683542210973364, - C_rc = 321.59375, - ω = 2.894680446328142, - Ω_dot = -8.184269479103281e-9, - IODE_Sub_3 = "11100001", - i_dot = -1.0071848104329588e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "1111100001", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.3642420526593924e-12, - a_f0 = 0.0003486964851617813, - IODE_Sub_2 = "11100001", - C_rs = -15.3125, - Δn = 4.35303846438188e-9, - M_0 = -0.6041391930737793, - C_uc = -8.717179298400879e-7, - e = 0.0014569575432687998, - C_us = 3.339722752571106e-6, - sqrt_A = 5153.662895202637, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 2.60770320892334e-8, - Ω_0 = 2.733796625968394, - C_is = -4.284083843231201e-8, - i_0 = 0.9683542210973364, - C_rc = 321.59375, - ω = 2.894680446328142, - Ω_dot = -8.184269479103281e-9, - IODE_Sub_3 = "11100001", - i_dot = -1.0071848104329588e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = -794.0022484221436Hz, - code_phase = 14504.587373634655, - carrier_phase = -0.7416765461612318, - ), - SatelliteState(; - decoder = decoder_state( - 23, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0011101110", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -3.751665644813329e-12, - a_f0 = 0.00010169018059968948, - IODE_Sub_2 = "11101110", - C_rs = -92.40625, - Δn = 4.153030133232073e-9, - M_0 = -1.3144457890174628, - C_uc = -4.732981324195862e-6, - e = 0.0012323472183197737, - C_us = 7.256865501403809e-6, - sqrt_A = 5153.657176971436, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -9.872019290924072e-8, - Ω_0 = -2.5503370450318865, - C_is = 2.9802322387695312e-8, - i_0 = 0.9644177455387573, - C_rc = 242.5, - ω = 2.603874239368067, - Ω_dot = -7.890328663859904e-9, - IODE_Sub_3 = "11101110", - i_dot = 3.107272287505937e-11, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0011101110", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -3.751665644813329e-12, - a_f0 = 0.00010169018059968948, - IODE_Sub_2 = "11101110", - C_rs = -92.40625, - Δn = 4.153030133232073e-9, - M_0 = -1.3144457890174628, - C_uc = -4.732981324195862e-6, - e = 0.0012323472183197737, - C_us = 7.256865501403809e-6, - sqrt_A = 5153.657176971436, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -9.872019290924072e-8, - Ω_0 = -2.5503370450318865, - C_is = 2.9802322387695312e-8, - i_0 = 0.9644177455387573, - C_rc = 242.5, - ω = 2.603874239368067, - Ω_dot = -7.890328663859904e-9, - IODE_Sub_3 = "11101110", - i_dot = 3.107272287505937e-11, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = 2826.783251528247Hz, - code_phase = 14394.465193705755, - carrier_phase = -0.7597910878699417, - ), - SatelliteState(; - decoder = decoder_state( - 26, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001101110", - l2pcode = false, - T_GD = 6.984919309616089e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 4.888534022029489e-12, - a_f0 = 9.242305532097816e-5, - IODE_Sub_2 = "01101110", - C_rs = 29.53125, - Δn = 5.002351225150362e-9, - M_0 = 3.0615288470223163, - C_uc = 1.5329569578170776e-6, - e = 0.00589060562197119, - C_us = 8.42846930027008e-6, - sqrt_A = 5153.568691253662, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.043081283569336e-7, - Ω_0 = 0.5730118600154152, - C_is = 1.30385160446167e-8, - i_0 = 0.942073253292395, - C_rc = 208.875, - ω = 0.3072108597425673, - Ω_dot = -8.057478483463671e-9, - IODE_Sub_3 = "01101110", - i_dot = -3.328710082707509e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001101110", - l2pcode = false, - T_GD = 6.984919309616089e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 4.888534022029489e-12, - a_f0 = 9.242305532097816e-5, - IODE_Sub_2 = "01101110", - C_rs = 29.53125, - Δn = 5.002351225150362e-9, - M_0 = 3.0615288470223163, - C_uc = 1.5329569578170776e-6, - e = 0.00589060562197119, - C_us = 8.42846930027008e-6, - sqrt_A = 5153.568691253662, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.043081283569336e-7, - Ω_0 = 0.5730118600154152, - C_is = 1.30385160446167e-8, - i_0 = 0.942073253292395, - C_rc = 208.875, - ω = 0.3072108597425673, - Ω_dot = -8.057478483463671e-9, - IODE_Sub_3 = "01101110", - i_dot = -3.328710082707509e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = -1083.2317687377372Hz, - code_phase = 13905.233170289455, - carrier_phase = -2.1444956909602526, - ), - SatelliteState(; - decoder = decoder_state( - 27, - GPSDataType( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001001110", - l2pcode = false, - T_GD = 1.862645149230957e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.366462912410498e-12, - a_f0 = -0.0001393994316458702, - IODE_Sub_2 = "01001110", - C_rs = 82.25, - Δn = 4.257677349351526e-9, - M_0 = 1.25128819523303, - C_uc = 4.505738615989685e-6, - e = 0.00944566575344652, - C_us = 6.81169331073761e-6, - sqrt_A = 5153.686637878418, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -5.029141902923584e-8, - Ω_0 = 1.66429757866306, - C_is = 1.601874828338623e-7, - i_0 = 0.9756900633046096, - C_rc = 257.375, - ω = 0.6281523291801417, - Ω_dot = -8.07676500111026e-9, - IODE_Sub_3 = "01001110", - i_dot = -1.2286226056345314e-10, - ), - GPSDataType( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001001110", - l2pcode = false, - T_GD = 1.862645149230957e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.366462912410498e-12, - a_f0 = -0.0001393994316458702, - IODE_Sub_2 = "01001110", - C_rs = 82.25, - Δn = 4.257677349351526e-9, - M_0 = 1.25128819523303, - C_uc = 4.505738615989685e-6, - e = 0.00944566575344652, - C_us = 6.81169331073761e-6, - sqrt_A = 5153.686637878418, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -5.029141902923584e-8, - Ω_0 = 1.66429757866306, - C_is = 1.601874828338623e-7, - i_0 = 0.9756900633046096, - C_rc = 257.375, - ω = 0.6281523291801417, - Ω_dot = -8.07676500111026e-9, - IODE_Sub_3 = "01001110", - i_dot = -1.2286226056345314e-10, - ), - GPSConstantsType( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GPSCacheType(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 3393.7445379085816Hz, - code_phase = 16861.02713837273, - carrier_phase = -0.9185401811870872, - ), - ] - return states -end +"9 GPS L1 C/A satellites over Aachen, 2021-05-31 (from test/fixtures.jl)." +make_gps_states() = gps_l1_states(0.0Hz) diff --git a/docs/src/api.md b/docs/src/api.md index 23ff545..de5dd2c 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -5,6 +5,7 @@ ```@docs SatelliteState PVTSolution +SatInfo ``` ## PVT Computation @@ -38,17 +39,26 @@ PositionVelocityTime._elevation_azimuth ## Dilution of Precision +The DOP values are read from the `dop` field of a [`PVTSolution`](@ref), e.g. +`pvt.dop.GDOP`. + ```@docs -get_gdop -get_pdop -get_hdop -get_vdop -get_tdop +PositionVelocityTime.DOP ``` ## Utilities ```@docs -get_num_used_sats -get_frequency_offset +get_sat_info ``` + +## Multi-GNSS Classification + +When [`calc_pvt`](@ref) combines constellations and bands it classifies each satellite by +three keys, all provided by GNSSSignals (3.3+) and read from a satellite's ranging signal: +`GNSSSignals.get_time_system` (a `GNSSSignals.TimeSystem`, i.e. `GPST()`/`GST()`) drives +receiver-clock grouping (one bias per time system); `GNSSSignals.get_band_id` (e.g. `:L1`, +`:L5`) drives inter-frequency-bias grouping (one bias per band); and +`GNSSSignals.get_signal_id` (e.g. `:GPSL1CA`) is the per-signal identity used in the `sats` +key of [`PVTSolution`](@ref). This package forwards each of the latter two to a +[`SatelliteState`](@ref). diff --git a/docs/src/index.md b/docs/src/index.md index 389452a..27cc144 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -5,9 +5,14 @@ Calculates position, velocity, and time from GNSS satellite measurements. ## Features - Estimation of user position, velocity, and time -- Satellite position and velocity calculation from orbital parameters +- Satellite position and velocity calculation from orbital parameters (LNAV and + CNAV/CNAV-2 quasi-Keplerian ephemerides) - Dilution of Precision (DOP) computation -- Support for GPS L1 and Galileo E1B +- Support for GPS (L1 C/A, L2C, L5, L1C) and Galileo (E1B, E5a), including combined + multi-GNSS solutions. Each measurement is one satellite-band pseudorange; the + group-delay/ISC correction is selected by the signal the range was generated on + (which may be a pilot such as GPS L1C-P or Galileo E1C), while the ephemeris and + clock come from the band's data-component decoder. ## Installation @@ -46,3 +51,9 @@ With at least 4 satellite states, compute the PVT solution: pvt = calc_pvt(sat_states) lla = get_LLA(pvt) # latitude, longitude, altitude ``` + +Satellites from different constellations may be passed together. Because each GNSS +references its broadcasts to its own system time, [`calc_pvt`](@ref) estimates one +receiver clock bias per GNSS time system, so a combined fix needs at least `3 + M` +satellites for `M` distinct systems. The per-system clock offsets are reported as +`pvt.inter_system_biases` relative to `pvt.reference_system`. diff --git a/ext/PositionVelocityTimeTrackingExt.jl b/ext/PositionVelocityTimeTrackingExt.jl index 13eec5b..f907012 100644 --- a/ext/PositionVelocityTimeTrackingExt.jl +++ b/ext/PositionVelocityTimeTrackingExt.jl @@ -5,15 +5,10 @@ using GNSSDecoder: GNSSDecoderState using GNSSSignals: AbstractGNSSSignal using Tracking: Tracking, get_code_phase, get_carrier_doppler, get_carrier_phase -# The name of the per-satellite tracking state changed between Tracking major versions: -# `SatState` up to Tracking 1, `TrackedSat` from Tracking 2 on. Bind to whichever exists so -# the extension supports both. -const TrackingSatState = isdefined(Tracking, :TrackedSat) ? Tracking.TrackedSat : Tracking.SatState - function PositionVelocityTime.SatelliteState( decoder::GNSSDecoderState, system::AbstractGNSSSignal, - sat_state::TrackingSatState, + sat_state::Tracking.TrackedSat, ) SatelliteState( decoder, diff --git a/src/PositionVelocityTime.jl b/src/PositionVelocityTime.jl index 696385e..66b7756 100755 --- a/src/PositionVelocityTime.jl +++ b/src/PositionVelocityTime.jl @@ -9,27 +9,66 @@ using CoordinateTransformations, LsqFit, StaticArrays, Unitful, - Statistics, Dates using Unitful: s, Hz - -const SPEEDOFLIGHT = 299792458.0 +using Dictionaries: Dictionary export calc_pvt, PVTSolution, + SatInfo, SatelliteState, get_LLA, - get_num_used_sats, + get_sat_info, calc_satellite_position, calc_satellite_position_and_velocity, - get_sat_enu, - get_gdop, - get_pdop, - get_hdop, - get_vdop, - get_tdop, - get_frequency_offset + get_sat_enu + +const SPEEDOFLIGHT = 299792458.0 + +# The GPS CNAV family (CNAV on L5/L2C, CNAV-2 on L1C): both broadcast the full week +# number (no 1024-week rollover) and a quasi-Keplerian ephemeris (A_REF + ΔA, +# Ω̇_REF + ΔΩ̇, …) rather than LNAV's directly-broadcast Keplerian elements — so +# `orbital_elements` and the full-week `get_week` dispatch on this union. +const GPSModernNavData = Union{GNSSDecoder.GPSCNAVData,GNSSDecoder.GPSL1C_DData} + +""" + BiasColumns + +Per-satellite assignment of the estimated bias columns of the least-squares design +matrix, shared by [`calc_ρ_hat!`](@ref), [`calc_H!`](@ref) and [`user_position`](@ref). +The state vector is `[x, y, z, tc₁, …, tc_num_clock_biases, ifb₁, …, ifb_num_ifb]` with +[`num_lsq_params`](@ref)`(bias_columns)` entries. The two column kinds have different +physical sources: a clock column is the receiver clock for one GNSS time system (the +spacing *between* systems is a system/space-segment effect — the GNSS time offset / +GGTO), whereas an inter-frequency-bias column is the receiver's per-band RF-chain delay. +Known per-satellite corrections (atmosphere, the GGTO time-system offset) are applied to +the pseudoranges in [`calc_pvt`](@ref), not carried here. + +# Fields +- `clock_bias_indices::Vector{Int}`: per satellite, the clock column (1…`num_clock_biases`) + of its GNSS time system; the design-matrix `1.0` lands at `3 + clock_bias_indices[j]`. +- `num_clock_biases::Int`: number of clock biases (also the offset of the IFB block). +- `ifb_indices::Vector{Int}`: per satellite, the inter-frequency-bias column + (1…`num_ifb`) of its frequency band, or `0` for the reference band; the `1.0` lands + at `3 + num_clock_biases + ifb_indices[j]`. +- `num_ifb::Int`: number of inter-frequency biases (frequency bands beyond the reference). +""" +struct BiasColumns + clock_bias_indices::Vector{Int} + num_clock_biases::Int + ifb_indices::Vector{Int} + num_ifb::Int +end + +""" + num_lsq_params(bias_columns::BiasColumns) -> Int + +Length of the least-squares state vector for `bias_columns`: +`3 + num_clock_biases + num_ifb`. +""" +num_lsq_params(bias_columns::BiasColumns) = + 3 + bias_columns.num_clock_biases + bias_columns.num_ifb """ SatelliteState{CP<:Real,D<:GNSSDecoderState,S<:AbstractGNSSSignal} @@ -48,9 +87,9 @@ Combines the GNSS decoder state with code and carrier phase measurements for a s SatelliteState(decoder, system, sat_state) The second constructor extracts code phase, carrier Doppler, and carrier phase from a -`Tracking` satellite state (`Tracking.SatState` for Tracking ≤ 1, `Tracking.TrackedSat` for -Tracking ≥ 2). It is provided by a package extension that is loaded automatically once -`Tracking` is available, so `Tracking` is only a weak dependency of this package. +`Tracking` satellite state (`Tracking.TrackedSat`). It is provided by a package extension +that is loaded automatically once `Tracking` is available, so `Tracking` is only a weak +dependency of this package. """ @kwdef struct SatelliteState{CP<:Real,D<:GNSSDecoder.GNSSDecoderState,S<:AbstractGNSSSignal} decoder::D @@ -81,9 +120,23 @@ struct DOP TDOP::Float64 end +""" + SatInfo + +Per-satellite information attached to a [`PVTSolution`](@ref) (one entry per +satellite used in the fix). + +# Fields +- `position::ECEF`: Satellite ECEF position at transmit time (metres). +- `time::Float64`: Satellite transmit time (system time of week, seconds). +- `residual::Float64`: Post-fit least-squares pseudorange residual (metres) — the + modeled minus the (atmosphere-corrected) measured pseudorange. A per-satellite + fit-quality / outlier indicator. +""" struct SatInfo position::ECEF time::Float64 + residual::Float64 end """ @@ -94,11 +147,38 @@ Complete Position, Velocity, and Time solution from GNSS measurements. # Fields - `position::ECEF`: User position in ECEF coordinates (meters) - `velocity::ECEF`: User velocity in ECEF coordinates (m/s) -- `time_correction::Float64`: Estimated receiver clock bias (meters) +- `time_correction::Float64`: Estimated receiver clock bias of the reference GNSS + (meters). For a multi-GNSS solution this is the bias of `reference_system`; + other systems' biases are `time_correction + inter_system_biases[system]`. - `time::Union{TAIEpoch{Float64}, Nothing}`: Estimated time as a TAI epoch - `relative_clock_drift::Float64`: Relative receiver clock drift (dimensionless) - `dop::Union{DOP, Nothing}`: Dilution of precision values -- `sats::Dict{Int, SatInfo}`: Dictionary mapping PRN to satellite info (position and time) +- `sats::Dictionary{Tuple{Symbol, Int}, SatInfo}`: Maps `(signal, PRN)` to satellite + info — position, transmit time, and post-fit residual (see [`SatInfo`](@ref)). The + signal tag (e.g. `:GPSL1CA`, `:GalileoE1B`; see `get_signal_id`) keeps the + same PRN apart both across constellations (GPS PRN 5 vs Galileo E05) and across + signals of one constellation (a satellite tracked on GPS L1 C/A and L5 yields two + entries sharing a PRN). Receiver-clock grouping is by time system, not signal — + see `reference_system`. +- `reference_system::Union{GNSSSignals.TimeSystem, Nothing}`: GNSS time system (e.g. + `GNSSSignals.GPST()`, `GST()`) that `time` and `time_correction` are referenced to. +- `inter_system_biases::Dict{GNSSSignals.TimeSystem, Float64}`: For each GNSS time system + other than `reference_system`, the offset of that system's time scale relative to the reference + system's (meters) — the inter-system bias. This is a **system / space-segment** effect, + not a receiver one (this receiver has no inter-system hardware bias): it is the GNSS + system-time offset, so the Galileo entry equals `−c · Δt_systems`, `Δt_systems = GST − + GPST` (the GGTO). It is **estimated directly from the geometry whenever observable** + (no broadcast error); the broadcast GGTO is used to derive it only as a fallback (the + GGTO-aided collapse), as that broadcast value may be erroneous. Reference-independent + (the difference of two entries is the offset between those two systems); empty for a + single-system solution. +- `inter_frequency_biases::Dict{Symbol, Float64}`: For each frequency band other than + the reference band, the receiver inter-frequency bias relative to it (meters) — the + differential hardware delay of that band's RF chain, estimated as an extra unknown + when satellites are processed on more than one band. The key is the band + (e.g. `:L1`, `:L5`; see `get_band_id`) and the value is shared across + all constellations on that band. Empty for a single-band solution. A solution then + needs `n ≥ 3 + M + B` satellites for `M` time systems and `B` extra bands. """ @kwdef struct PVTSolution position::ECEF = ECEF(0, 0, 0) @@ -107,80 +187,231 @@ Complete Position, Velocity, and Time solution from GNSS measurements. time::Union{TAIEpoch{Float64},Nothing} = nothing relative_clock_drift::Float64 = 0 dop::Union{DOP,Nothing} = nothing - sats::Dict{Int,SatInfo} = Dict{Int,SatInfo}() + sats::Dictionary{Tuple{Symbol,Int},SatInfo} = Dictionary{Tuple{Symbol,Int},SatInfo}() + reference_system::Union{GNSSSignals.TimeSystem,Nothing} = nothing + inter_system_biases::Dict{GNSSSignals.TimeSystem,Float64} = + Dict{GNSSSignals.TimeSystem,Float64}() + inter_frequency_biases::Dict{Symbol,Float64} = Dict{Symbol,Float64}() end """ - get_num_used_sats(pvt_solution::PVTSolution) -> Int + get_sat_info(pvt_solution::PVTSolution, signal::Symbol, prn::Integer) -> Union{SatInfo,Nothing} -Return the number of satellites used in the PVT solution. +Return the [`SatInfo`](@ref) (position, transmit time and post-fit residual) of the +satellite with the given `prn` on GNSS `signal` (e.g. `:GPSL1CA`, `:GalileoE1B`; see +`get_signal_id`), or `nothing` if that satellite was not used in the fix. The +signal tag is required because the same PRN can belong to different constellations or +be tracked on several signals; see the `sats` field of [`PVTSolution`](@ref). """ -function get_num_used_sats(pvt_solution::PVTSolution) - length(pvt_solution.sats) +function get_sat_info(pvt_solution::PVTSolution, signal::Symbol, prn::Integer) + get(pvt_solution.sats, (signal, Int(prn)), nothing) end """ - get_gdop(pvt_sol::PVTSolution) -> Float64 + get_sat_enu(user_pos_ecef::ECEF, sat_pos_ecef::ECEF) -> Spherical + get_sat_enu(enu_from_ecef::ENUfromECEF, sat_pos_ecef::ECEF) -> Spherical -Return the Geometric Dilution of Precision (GDOP) from the PVT solution. -""" -function get_gdop(pvt_sol::PVTSolution) - return pvt_sol.dop.GDOP -end +Convert satellite position to East-North-Up (ENU) spherical coordinates (azimuth and +elevation) relative to the user position. -""" - get_pdop(pvt_sol::PVTSolution) -> Float64 +# Arguments +- `user_pos_ecef::ECEF`: User position in ECEF coordinates +- `enu_from_ecef::ENUfromECEF`: A precomputed `ENUfromECEF(user_pos_ecef, wgs84)` + transform. Pass this form to reuse one transform across all satellites that + share a user position (one geodetic conversion per epoch instead of per satellite). +- `sat_pos_ecef::ECEF`: Satellite position in ECEF coordinates -Return the Position Dilution of Precision (PDOP) from the PVT solution. +# Returns +Spherical coordinates containing azimuth and elevation of the satellite as seen from +the user position. """ -function get_pdop(pvt_sol::PVTSolution) - return pvt_sol.dop.PDOP +function get_sat_enu(enu_from_ecef::ENUfromECEF, sat_pos_ecef::ECEF) + SphericalFromCartesian()(enu_from_ecef(sat_pos_ecef)) end -""" - get_vdop(pvt_sol::PVTSolution) -> Float64 - -Return the Vertical Dilution of Precision (VDOP) from the PVT solution. -""" -function get_vdop(pvt_sol::PVTSolution) - return pvt_sol.dop.VDOP -end +get_sat_enu(user_pos_ecef::ECEF, sat_pos_ecef::ECEF) = + get_sat_enu(ENUfromECEF(user_pos_ecef, wgs84), sat_pos_ecef) """ - get_hdop(pvt_sol::PVTSolution) -> Float64 - -Return the Horizontal Dilution of Precision (HDOP) from the PVT solution. + band_ifb_layout(systems, bands) -> (ifb_indices, extra_bands, num_components) + +Lay out the receiver inter-frequency biases from the (constellation × band) coverage +graph. Two bands share a *coverage component* iff some constellation is tracked on +both (directly or through a chain of shared constellations); within a component the +clock/IFB split has a single gauge freedom, so one reference band per component (the +most-populated, ties by first appearance) is fixed and an IFB column is created only +for the *other* bands of that component. This estimates exactly the observable IFBs — +a band that is the sole band of its component (its constellation lives only there) +gets none, its delay folding into that constellation's clock — so the resulting design +matrix is always full rank. + +`ifb_indices[j]` is satellite `j`'s IFB column (1…`length(extra_bands)`), or `0` for a +per-component reference band; `extra_bands[i]` is the band of IFB column `i` (ordered +deterministically); `num_components` is the number of coverage components (`1` ⇔ the +graph is connected). """ -function get_hdop(pvt_sol::PVTSolution) - return pvt_sol.dop.HDOP +function band_ifb_layout(systems, bands) + unique_bands = unique(bands) + # Union-find over bands: union the bands a single constellation is tracked on. + parent = Dict(b => b for b in unique_bands) + root(b) = parent[b] == b ? b : (parent[b] = root(parent[b])) + function link_bands!(a, c) + ra, rc = root(a), root(c) + ra == rc || (parent[ra] = rc) + end + for sys in unique(systems) + sys_bands = unique(bands[i] for i in eachindex(systems) if systems[i] == sys) + for k in 2:length(sys_bands) + link_bands!(sys_bands[1], sys_bands[k]) + end + end + band_count = Dict{eltype(unique_bands),Int}() + for b in bands + band_count[b] = get(band_count, b, 0) + 1 + end + # Reference band per component = most-populated in the component (ties: first seen). + reference_of = Dict{eltype(unique_bands),eltype(unique_bands)}() + for b in unique_bands + r = root(b) + if !haskey(reference_of, r) || band_count[b] > band_count[reference_of[r]] + reference_of[r] = b + end + end + extra_bands = filter(b -> reference_of[root(b)] != b, unique_bands) + band_column = Dict(b => i for (i, b) in enumerate(extra_bands)) + ifb_indices = [get(band_column, b, 0) for b in bands] + return ifb_indices, extra_bands, length(reference_of) end """ - get_tdop(pvt_sol::PVTSolution) -> Float64 - -Return the Time Dilution of Precision (TDOP) from the PVT solution. + decide_bias_layout(states, systems, bands, times) + -> (bias_columns::BiasColumns, extra_bands, ggto_offsets) + +Decide the full least-squares bias layout — one clock column per GNSS time system plus +the per-band inter-frequency-bias columns from [`band_ifb_layout`](@ref) — and the +known per-satellite range offsets. Returns `nothing` when the constellation cannot be +solved. The decision is observability-driven, not merely count-driven: + +- When the coverage graph is connected and `n ≥ 3 + num_systems + num_ifb`, estimate + everything independently: the inter-system offset and the receiver inter-frequency + biases are observed directly from the geometry, so neither inherits the broadcast-GGTO + error (the satellite group delays are already removed per satellite upstream, so the + per-band column carries the receiver chain). +- Otherwise merge the Galileo clock onto GPS via the broadcast GGTO when a Galileo + satellite carries it. This removes a clock unknown (the scarce-satellite case) and + reconnects a disjoint GPS/Galileo band split (the disconnected case — where a band's + IFB column would otherwise be collinear with the stranded constellation's clock), + making the inter-frequency bias observable again (it then carries the broadcast-GGTO + error, alongside the GGTO-based inter-system bias). +- Failing that, fall back to the (already observability-restricted) independent layout + if the satellite count allows. No IFB column is created for a band stranded on its own + constellation, so its inter-frequency bias folds into that constellation's clock and + the reported inter-system bias reads out as `GGTO + (IFB difference)` — the two are no + longer separable. Position, residuals and DOP are still finite and non-degenerate; + only the bias decomposition is ambiguous. Else return `nothing`. + +Because `band_ifb_layout` never creates an unobservable IFB column, every returned +layout yields a full-rank design matrix — the degenerate disjoint-band case is removed +structurally, not caught after the fact. """ -function get_tdop(pvt_sol::PVTSolution) - return pvt_sol.dop.TDOP -end +function decide_bias_layout(states, systems, bands, times) + num_sats = length(states) + enough_satellites(layout) = + num_sats >= 3 + layout.num_clock_biases + length(layout.extra_bands) + + function bias_layout_for(effective_systems) + unique_effective = unique(effective_systems) + index = Dict(sys => i for (i, sys) in enumerate(unique_effective)) + clock_bias_indices = [index[sys] for sys in effective_systems] + ifb_indices, extra_bands, num_components = band_ifb_layout(effective_systems, bands) + (; clock_bias_indices, num_clock_biases = length(unique_effective), ifb_indices, + extra_bands, num_components) + end -""" - get_sat_enu(user_pos_ecef::ECEF, sat_pos_ecef::ECEF) -> Spherical + as_result(layout, ggto_offsets) = ( + BiasColumns(layout.clock_bias_indices, layout.num_clock_biases, + layout.ifb_indices, length(layout.extra_bands)), + layout.extra_bands, + ggto_offsets, + ) -Convert satellite position to East-North-Up (ENU) spherical coordinates (azimuth and -elevation) relative to the user position. + independent_layout = bias_layout_for(systems) + if independent_layout.num_components == 1 && enough_satellites(independent_layout) + return as_result(independent_layout, zeros(num_sats)) + end -# Arguments -- `user_pos_ecef::ECEF`: User position in ECEF coordinates -- `sat_pos_ecef::ECEF`: Satellite position in ECEF coordinates + # Connected-but-scarce or disconnected: try the GGTO collapse (merge Galileo onto + # GPS). The GGTO is the same constellation-wide offset whichever Galileo satellite + # reports it, so one decoded copy converts every Galileo measurement. + ggto_idx = findfirst( + j -> systems[j] == GST() && ggto_available(states[j].decoder), 1:num_sats) + if (GPST() in systems) && !isnothing(ggto_idx) + merged_layout = bias_layout_for(map(sys -> sys == GST() ? GPST() : sys, systems)) + if enough_satellites(merged_layout) + # Per the OS SIS ICD the GGTO is GST − GPST, so a Galileo transmit time + # becomes GPS time by SUBTRACTING it; the modeled range carries −c·GGTO and + # the solve yields inter_system_biases[GST()] = −c·(GST − GPST). + ggto_decoder = states[ggto_idx].decoder + ggto_offsets = zeros(num_sats) + for j in 1:num_sats + systems[j] == GST() || continue + ggto_offsets[j] = -SPEEDOFLIGHT * calc_ggto_offset(ggto_decoder, times[j]) + end + return as_result(merged_layout, ggto_offsets) + end + end + + # No collapse available. The independent layout is still observable (its IFBs are + # component-restricted); use it if there are enough satellites, otherwise unsolvable. + enough_satellites(independent_layout) ? + as_result(independent_layout, zeros(num_sats)) : nothing +end -# Returns -Spherical coordinates containing azimuth and elevation of the satellite as seen from -the user position. """ -function get_sat_enu(user_pos_ecef::ECEF, sat_pos_ecef::ECEF) - sat_enu = ENUfromECEF(user_pos_ecef, wgs84)(sat_pos_ecef) - SphericalFromCartesian()(sat_enu) + predict_atmospheric_delays(ξ, states, sat_positions, correction, + reference_time, enable_tropospheric_correction) -> Vector{Float64} + +Per-satellite ionospheric + tropospheric delay (metres), to be subtracted from the +pseudoranges. The user position is the first three elements of the least-squares +state vector `ξ = [x, y, z, tc₁, …]` (ECEF, metres); the remaining clock-bias +components are ignored. `correction` is the constellation-wide ionospheric model +from [`select_ionospheric_correction`](@ref) (`nothing` skips the ionosphere); the +troposphere uses the blind Saastamoinen model (see [`tropospheric_delay`](@ref)) +unless `enable_tropospheric_correction` is `false`. + +A single corrected solve is enough: the delays depend on position only through the +satellite elevation/azimuth (and, for the troposphere, the user height), and +∂delay/∂position is negligible over the metre-level position uncertainty (a 15 m +shift moves the elevation by ~1e-5°), so delays predicted at a nearby position are +accurate to well under a millimetre — no iterate-to-convergence needed. The user +geodetic coordinates and the ENU transform depend only on `ξ`, so they are +computed once and reused across satellites. +""" +function predict_atmospheric_delays( + ξ, + states, + sat_positions, + correction, + reference_time, + enable_tropospheric_correction, +) + user_pos = ECEF(ξ[1], ξ[2], ξ[3]) + user_lla = LLAfromECEF(wgs84)(user_pos) + enu_from_ecef = ENUfromECEF(user_pos, wgs84) + map(states, sat_positions) do state, sat_pos + elevation, azimuth = _elevation_azimuth(enu_from_ecef, sat_pos) + iono = ionospheric_delay( + correction, + state.system, + elevation, + azimuth, + user_lla, + reference_time, + ) + tropo = + enable_tropospheric_correction ? tropospheric_delay(elevation, user_lla) : 0.0 + iono + tropo + end end """ @@ -192,9 +423,24 @@ end Calculate Position, Velocity, and Time (PVT) from GNSS satellite measurements. -Requires at least 4 healthy satellites from the same GNSS system. Uses least-squares -estimation for position and time, and solves for velocity and clock drift from -carrier Doppler measurements. +Satellites from different GNSS and frequency bands may be combined. Because each +constellation references its broadcasts to its own system time, one receiver clock +bias is estimated per GNSS time system; and because the receiver's RF chain delays +each band differently, one receiver inter-frequency bias is estimated per frequency +band beyond a reference band (shared across constellations on that band; see +`get_band_id`). The state vector is therefore +`[x, y, z, tc₁, …, tc_M, ifb₁, …, ifb_B]` for `M` distinct time systems and `B` +extra bands. Position and time are found by least squares; velocity and clock drift +are solved from carrier Doppler. + +A solution requires `n ≥ 3 + M + B` healthy satellites (each system needs at least +one satellite, and a system contributing a single satellite spends it entirely +on that system's clock bias). When that condition fails but both GPS and Galileo +are present and the Galileo message carries the GGTO (Galileo–GPS Time Offset), +the Galileo clock bias is collapsed onto GPS using the broadcast offset, which +makes a 4-satellite GPS+Galileo fix possible. Estimating an independent bias is +preferred whenever the geometry allows it, since it avoids the GGTO's own +broadcast error. Unless disabled via `enable_ionospheric_correction`, the ionospheric delay is corrected automatically using only the coefficients decoded from the navigation @@ -209,7 +455,10 @@ corrected with the blind Saastamoinen model (no broadcast coefficients needed). See [`tropospheric_delay`](@ref). # Arguments -- `states`: Vector of [`SatelliteState`](@ref) for observed satellites +- `states`: Vector of [`SatelliteState`](@ref) for observed satellites. Each + (signal, PRN) pair must appear at most once — a receiver produces one + measurement per signal per satellite, and a duplicate would enter the + least-squares solve twice. - `prev_pvt`: Previous PVT solution used as initial guess (default: origin) # Keyword Arguments @@ -229,8 +478,9 @@ See [`tropospheric_delay`](@ref). # Returns A [`PVTSolution`](@ref) containing position, velocity, time, DOP values, and -satellite information. Returns `prev_pvt` if fewer than 4 healthy satellites are -available or if the GDOP is negative. +satellite information. Returns `prev_pvt` if too few healthy satellites are +available to solve the constellation (including the GGTO fallback) or if the +GDOP is negative. # Throws - `ArgumentError`: If fewer than 4 satellite states are provided @@ -244,82 +494,116 @@ function calc_pvt( ) length(states) < 4 && throw(ArgumentError("You'll need at least 4 satellites to calculate PVT")) - all(state -> state.system == states[1].system, states) || - ArgumentError("For now all satellites need to be base on the same GNSS") - system = first(states).system healthy_indices = findall(x -> is_sat_healthy(x.decoder), states) length(healthy_indices) < 4 && return prev_pvt healthy_states = view(states, healthy_indices) - prev_ξ = [prev_pvt.position; prev_pvt.time_correction] - healthy_prns = map(state -> state.decoder.prn, healthy_states) - times = map(state -> calc_corrected_time(state), healthy_states) + num_sats = length(healthy_states) + + times = map(calc_corrected_time, healthy_states) + + # Classify each satellite by the GNSSSignals keys that drive the solution. + # `get_time_system` (`GPST()`/`GST()`) groups the receiver clock bias — one per time + # system, ordered by first appearance; `get_band_id` (`:L1`, `:L5`, …) groups the + # receiver inter-frequency bias — one per band beyond a per-coverage-component + # reference. (`get_signal_id`, `:GPSL1CA` …, is the per-signal `sats` identity used + # below, not a grouping key.) `decide_bias_layout` then decides the full bias layout: + # it keeps only observable IFBs and falls back to a GGTO collapse when the geometry + # is disconnected or under-determined. + systems = map(state -> get_time_system(state.system), healthy_states) + bands = map(state -> get_band_id(state.system), healthy_states) + + bias_layout = decide_bias_layout(healthy_states, systems, bands, times) + isnothing(bias_layout) && return prev_pvt + bias_columns, extra_bands, ggto_offsets = bias_layout + (; clock_bias_indices, num_clock_biases) = bias_columns + + # Propagating the ephemerides. sat_positions_and_velocities = map( (state, time) -> calc_satellite_position_and_velocity(state.decoder, time), healthy_states, times, ) sat_positions = map(get_sat_position, sat_positions_and_velocities) + # Built once here and reused below. `stack` collects the SVector + # columns into a single 3×N `Matrix{Float64}` in one pass. + sat_positions_mat = stack(sat_positions) + + # Primary system — its clock bias, reference time, week and start epoch + # define the reported time. A GGTO collapse (signalled by fewer clock biases + # than systems) is anchored on GPS, so GPS must be primary there; otherwise + # pick the system with the most satellites (best-conditioned reported time), + # breaking ties by first appearance. + unique_systems = unique(systems) + primary_system = + num_clock_biases < length(unique_systems) ? GPST() : + unique_systems[argmax([count(==(sys), systems) for sys in unique_systems])] + primary_clock_index = clock_bias_indices[findfirst(==(primary_system), systems)] + + # The common reference cancels out of the reported time (the primary clock + # bias absorbs it), so any latest-transmit-time reference works. pseudo_ranges, reference_time = calc_pseudo_ranges(times) + # Apply the known per-satellite GGTO time-system offset (zero unless Galileo was + # collapsed onto GPS) as a measurement correction, like the atmospheric delays + # below. + pseudo_ranges = pseudo_ranges .- ggto_offsets + + # Seed each clock bias from the previous solution, reconstructing a system's + # absolute bias from the reference bias plus its stored inter-system bias. + prev_abs_bias(sys) = + sys == prev_pvt.reference_system ? prev_pvt.time_correction : + prev_pvt.time_correction + get(prev_pvt.inter_system_biases, sys, 0.0) + prev_ξ = zeros(num_lsq_params(bias_columns)) + prev_ξ[1], prev_ξ[2], prev_ξ[3] = prev_pvt.position + for j in 1:num_sats + prev_ξ[3+clock_bias_indices[j]] = prev_abs_bias(systems[j]) + end + for (i, band) in enumerate(extra_bands) + prev_ξ[3+num_clock_biases+i] = get(prev_pvt.inter_frequency_biases, band, 0.0) + end + # Atmospheric corrections, summed per satellite and subtracted from the - # pseudoranges. The ionospheric model is chosen for the whole solve from the - # coefficients decoded across the constellation (NTCM-G if Galileo coefficients - # are available, else Klobuchar if GPS α/β are available, else none; see - # `select_ionospheric_correction`); the troposphere uses the blind Saastamoinen - # model (see `tropospheric_delay`). A single corrected solve is enough: the - # delays depend on position only through the satellite elevation/azimuth (and, - # for the troposphere, the user height), and ∂delay/∂position is negligible over - # the metre-level position uncertainty (a 15 m shift moves the elevation by - # ~1e-5°), so delays predicted at a nearby position are accurate to well under a - # millimetre — no iterate-to-convergence needed. - correction = + # pseudoranges. The ionospheric model is chosen for the whole solve. The + # prediction is the top-level `predict_atmospheric_delays`, a function + # barrier that specialises on the concrete type of the `Union`-typed + # `ionospheric_correction`. + ionospheric_correction = enable_ionospheric_correction ? select_ionospheric_correction(healthy_states) : nothing - function predict_atmospheric_delays(position) - user_pos = ECEF(position[1], position[2], position[3]) - # The user position is the same for every satellite, so the geodetic - # coordinates and the ENU transform are computed once per epoch and reused. - user_lla = LLAfromECEF(wgs84)(user_pos) - enu_from_ecef = ENUfromECEF(user_pos, wgs84) - map(healthy_states, sat_positions) do state, sat_pos - elevation, azimuth = _elevation_azimuth(enu_from_ecef, sat_pos) - iono = ionospheric_delay( - correction, - state.system, - elevation, - azimuth, - user_lla, - reference_time, - ) - tropo = - enable_tropospheric_correction ? - tropospheric_delay(elevation, user_lla) : 0.0 - iono + tropo - end - end - ξ, rmse = if iszero(prev_ξ) + # Is any atmospheric correction active at all? When neither the ionosphere (no + # model selected) nor the troposphere contributes, skip `predict_atmospheric_delays` + # entirely and the solve runs on the raw pseudoranges. + correct_atmosphere = + !isnothing(ionospheric_correction) || enable_tropospheric_correction + ξ, residuals = if iszero(prev_ξ) # Cold start: no prior position, and the Klobuchar model is undefined near # the geocenter, so first obtain an approximate fix from an uncorrected # solve, then re-solve once with the delay-corrected pseudoranges (only if # there is anything to correct, so the uncorrected case stays a single solve). - ξ_uncorrected, rmse_uncorrected = - user_position(sat_positions, pseudo_ranges, prev_ξ) - atmospheric_delays = predict_atmospheric_delays(ξ_uncorrected) - any(!iszero, atmospheric_delays) ? - user_position(sat_positions, pseudo_ranges .- atmospheric_delays, ξ_uncorrected) : - (ξ_uncorrected, rmse_uncorrected) + ξ_uncorrected, resid_uncorrected = + user_position(sat_positions_mat, pseudo_ranges, bias_columns, prev_ξ) + if correct_atmosphere + atmospheric_delays = predict_atmospheric_delays( + ξ_uncorrected, healthy_states, sat_positions, ionospheric_correction, + reference_time, enable_tropospheric_correction) + user_position( + sat_positions_mat, pseudo_ranges .- atmospheric_delays, bias_columns, ξ_uncorrected) + else + (ξ_uncorrected, resid_uncorrected) + end else # Warm start: predict the delays from the previous (already metre-accurate) - # position before solving, so ξ never needs a post-solve correction. - user_position( - sat_positions, - pseudo_ranges .- predict_atmospheric_delays(prev_ξ), - prev_ξ, - ) + # position before solving, so ξ never needs a post-solve correction. With no + # active correction, solve directly on the raw pseudoranges. + corrected_ranges = + correct_atmosphere ? + pseudo_ranges .- predict_atmospheric_delays( + prev_ξ, healthy_states, sat_positions, ionospheric_correction, + reference_time, enable_tropospheric_correction) : pseudo_ranges + user_position(sat_positions_mat, corrected_ranges, bias_columns, prev_ξ) end - sat_positions_mat = reduce(hcat, sat_positions) - H = calc_H(sat_positions_mat, ξ) + H = calc_H(sat_positions_mat, ξ, bias_columns) user_velocity_and_clock_drift = calc_user_velocity_and_clock_drift( - sat_positions_and_velocities, ξ, healthy_states, times, H) + sat_positions_and_velocities, healthy_states, times, H) position = ECEF(ξ[1], ξ[2], ξ[3]) velocity = ECEF( user_velocity_and_clock_drift[1], @@ -327,25 +611,53 @@ function calc_pvt( user_velocity_and_clock_drift[3], ) relative_clock_drift = user_velocity_and_clock_drift[4] / SPEEDOFLIGHT - time_correction = ξ[4] + time_correction = ξ[3+primary_clock_index] # The estimated time correction is negative # See https://github.com/JuliaGNSS/PositionVelocityTime.jl/issues/8 corrected_reference_time = reference_time - time_correction / SPEEDOFLIGHT - week = get_week(first(healthy_states).decoder; approximate_year) - start_time = get_system_start_time(first(healthy_states).decoder) + primary_state = healthy_states[findfirst(==(primary_system), systems)] + week = get_week(primary_state.decoder; approximate_year) + start_time = system_start_epoch(primary_state.system) + # Assumes `start_time.fraction == 0` (true for GPS/Galileo: integer-second origins). time = TAIEpoch( week * 7 * 24 * 60 * 60 + floor(Int, corrected_reference_time) + start_time.second, corrected_reference_time - floor(Int, corrected_reference_time), ) - sat_infos = SatInfo.(sat_positions, times) + sat_infos = SatInfo.(sat_positions, times, residuals) - dop = calc_DOP(H) + dop = calc_DOP(H, position, primary_clock_index) if dop.GDOP < 0 return prev_pvt end + # Inter-system biases relative to the reference (primary) system's clock, in + # meters. The reference is omitted (its bias is `time_correction`); for a + # GGTO-collapsed system this is the broadcast offset −c·Δt_systems, read from the + # system's first satellite (the per-satellite offsets differ only by the GGTO + # drift A_1G over the transmit-time spread — sub-millimetre). + inter_system_biases = Dict{GNSSSignals.TimeSystem,Float64}() + for sys in unique_systems + sys == primary_system && continue + j = findfirst(==(sys), systems) + inter_system_biases[sys] = + ξ[3+clock_bias_indices[j]] + ggto_offsets[j] - time_correction + end + + # Receiver inter-frequency biases relative to the reference band, in meters + # (the reference band is omitted; its bias is folded into the clock biases). + inter_frequency_biases = Dict{Symbol,Float64}() + for (i, band) in enumerate(extra_bands) + inter_frequency_biases[band] = ξ[3+num_clock_biases+i] + end + + # Per-satellite `sats` key: (signal id, PRN) — signal-level (not time system), so a + # satellite tracked on two signals of one constellation stays distinct; the + # receiver-clock grouping is separate, by time system. + healthy_sat_keys = + map(state -> (get_signal_id(state.system), state.decoder.prn), healthy_states) + PVTSolution( position, velocity, @@ -353,37 +665,24 @@ function calc_pvt( time, relative_clock_drift, dop, - Dict(healthy_prns .=> sat_infos), + Dictionary(healthy_sat_keys, sat_infos), + primary_system, + inter_system_biases, + inter_frequency_biases, ) end """ - get_frequency_offset(pvt::PVTSolution, base_frequency) -> typeof(base_frequency) + system_start_epoch(system) -> TAIEpoch -Calculate the receiver frequency offset from the relative clock drift and a base frequency. - -# Arguments -- `pvt::PVTSolution`: PVT solution containing the relative clock drift -- `base_frequency`: Reference frequency (e.g., the carrier frequency of the GNSS signal) - -# Returns -The frequency offset as `relative_clock_drift * base_frequency`. +Absolute TAI epoch of a ranging signal's GNSS time-scale origin (week 0, time of +week 0). `get_system_start_time` gives the origin as a UTC `DateTime`; +AstroTime's leap-aware UTC→TAI conversion places it on the atomic scale (GPS +`1980-01-06T00:00:19` TAI, Galileo `1999-08-22T00:00:19` TAI). The leap-second count +lives in AstroTime, not in this package. """ -function get_frequency_offset(pvt::PVTSolution, base_frequency) - pvt.relative_clock_drift * base_frequency -end - -function get_system_start_time( - decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1CAData}, -) - TAIEpoch(1980, 1, 6, 0, 0, 19.0) # There were 19 leap seconds at 01/06/1999 compared to UTC -end - -function get_system_start_time( - decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GalileoE1BData}, -) - TAIEpoch(1999, 8, 22, 0, 0, (32 - 13.0)) # There were 32 leap seconds at 08/22/1999 compared to UTC -end +system_start_epoch(system) = + from_utc(get_system_start_time(system); scale = TAI) """ get_week(decoder::GNSSDecoderState{<:GPSL1CAData}; approximate_year) @@ -417,8 +716,11 @@ function get_week( return n_cycles * 1024 + decoder.data.trans_week end +# Galileo I/NAV and F/NAV (12-bit WN) and GPS CNAV (L5, L2C) / CNAV-2 (L1C) (full +# 13-bit WN) all broadcast the absolute week number, so — unlike GPS L1 C/A LNAV — +# there is no 1024-week rollover to resolve and `approximate_year` is unused. function get_week( - decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GalileoE1BData}; + decoder::GNSSDecoder.GNSSDecoderState{<:Union{GNSSDecoder.AbstractGalileoData,GPSModernNavData}}; approximate_year::Integer = year(now(UTC)), ) decoder.data.WN diff --git a/src/ionosphere.jl b/src/ionosphere.jl index 10b5058..a02ce7d 100644 --- a/src/ionosphere.jl +++ b/src/ionosphere.jl @@ -20,11 +20,6 @@ # not just the band it was broadcast on. # =========================================================================== -# The frequency the Klobuchar group delay refers to (GPS L1), taken from GNSSSignals -# so it stays consistent with the per-satellite carrier frequencies used below. Kept -# as a Hz quantity: only the (f_L1/f) ratio is used, which is dimensionless. -const _GPS_L1_FREQUENCY = GNSSSignals.get_center_frequency(GPSL1CA()) - """ KlobucharParams(α_0, α_1, α_2, α_3, β_0, β_1, β_2, β_3) @@ -60,25 +55,31 @@ end """ klobuchar_params(decoder) -> Union{KlobucharParams,Nothing} -Klobuchar α/β decoded from a GPS L1 navigation message, or `nothing` if they have -not been broadcast yet (subframe 4, page 18) or the decoder is not GPS L1. +Klobuchar α/β decoded from a GPS navigation message (LNAV `GPSL1CAData`, CNAV +`GPSCNAVData` on L5/L2C, or CNAV-2 `GPSL1C_DData`), or `nothing` if they have not +been broadcast yet or the decoder is not a GPS signal. The same single-frequency +Klobuchar model is broadcast on all GPS civil signals. """ klobuchar_params(decoder) = nothing -function klobuchar_params(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1CAData}) +function klobuchar_params(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.AbstractGPSData}) d = decoder.data - (isnothing(d.α_0) || isnothing(d.β_0)) && return nothing + # All eight coefficients must be present: they are decoded together (subframe 4, + # page 18), but guard each so a partially-populated decoder returns `nothing` + # rather than throwing when a `nothing` hits a `Float64` field. + any(isnothing, (d.α_0, d.α_1, d.α_2, d.α_3, d.β_0, d.β_1, d.β_2, d.β_3)) && + return nothing return KlobucharParams(d.α_0, d.α_1, d.α_2, d.α_3, d.β_0, d.β_1, d.β_2, d.β_3) end """ ntcm_g_params(decoder) -> Union{NTCMGParams,Nothing} -NTCM-G Effective Ionisation Level coefficients decoded from a Galileo E1B -navigation message, or `nothing` if they (or the week number) have not been -decoded yet or the decoder is not Galileo E1B. +NTCM-G Effective Ionisation Level coefficients decoded from a Galileo navigation +message (I/NAV on E1B or F/NAV on E5a — both broadcast `a_i0…a_i2`), or `nothing` +if they (or the week number) have not been decoded yet or the decoder is not Galileo. """ ntcm_g_params(decoder) = nothing -function ntcm_g_params(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GalileoE1BData}) +function ntcm_g_params(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.AbstractGalileoData}) d = decoder.data (isnothing(d.a_i0) || isnothing(d.a_i1) || isnothing(d.a_i2) || isnothing(d.WN)) && return nothing @@ -106,7 +107,7 @@ function select_ionospheric_correction(states) end """ - ionospheric_delay(correction, system, elevation, azimuth, lla, gps_time) -> Float64 + ionospheric_delay(correction, system, elevation, azimuth, lla, time_of_week) -> Float64 Slant ionospheric group delay in metres for one satellite (`system` is the satellite's GNSS, used for its carrier frequency), using the constellation-wide @@ -117,39 +118,38 @@ satellite's GNSS, used for its carrier frequency), using the constellation-wide - [`NTCMGParams`](@ref) → NTCM-G model. The line of sight is given by the satellite `elevation`/`azimuth` (radians) and -the user geodetic position `lla` (a `Geodesy.LLA`); `gps_time` is the system time -of week of the measurement in seconds. The geometry is taken precomputed — and +the user geodetic position `lla` (a `Geodesy.LLA`); `time_of_week` is the measurement's +system time of week in seconds (GPS time for GPS/Klobuchar, GST for Galileo/NTCM-G — +they coincide to within the sub-microsecond GGTO, immaterial here). The geometry is taken precomputed — and shared across satellites and with [`tropospheric_delay`](@ref) — so a whole-epoch correction does the user geodetic conversion only once. Derive the geometry from ECEF with `LLAfromECEF(wgs84)(user)` and [`_elevation_azimuth`](@ref)`(ENUfromECEF(user, wgs84), sat)`. """ -ionospheric_delay(::Nothing, system, elevation, azimuth, lla, gps_time) = 0.0 +ionospheric_delay(::Nothing, system, elevation, azimuth, lla, time_of_week) = 0.0 -function ionospheric_delay(p::KlobucharParams, system, elevation, azimuth, lla, gps_time) +function ionospheric_delay(p::KlobucharParams, system, elevation, azimuth, lla, time_of_week) # IS-GPS-200 works in semicircles: lat/lon in deg/180, elevation/azimuth in rad/π. l1_seconds = klobuchar_group_delay( lla.lat / 180, lla.lon / 180, elevation / π, azimuth / π, - gps_time, + time_of_week, (p.α_0, p.α_1, p.α_2, p.α_3), (p.β_0, p.β_1, p.β_2, p.β_3), ) # The Klobuchar broadcast coefficients define the group delay at the GPS L1 # frequency (IS-GPS-200). The ionospheric delay scales as 1/f², so rescale it - # to this satellite's actual carrier frequency — yielding the correct (larger) - # delay on lower bands such as L2, L5/E5a and E6. For an L1/E1 signal the - # factor is exactly 1. The ratio of two Hz quantities is dimensionless. - f = GNSSSignals.get_center_frequency(system) - return SPEEDOFLIGHT * l1_seconds * (_GPS_L1_FREQUENCY / f)^2 + # to this satellite's actual carrier frequency. + f = get_center_frequency(system) + return SPEEDOFLIGHT * l1_seconds * (get_center_frequency(GPSL1CA) / f)^2 end -function ionospheric_delay(p::NTCMGParams, system, elevation, azimuth, lla, gps_time) - doy, ut = _galileo_doy_and_ut(p.week_number, gps_time) +function ionospheric_delay(p::NTCMGParams, system, elevation, azimuth, lla, time_of_week) + doy, ut = _galileo_doy_and_ut(p.week_number, time_of_week) stec = ntcm_g_stec(elevation, azimuth, lla, doy, ut, p.a_i0, p.a_i1, p.a_i2) # TECU - f = ustrip(Hz, GNSSSignals.get_center_frequency(system)) + f = ustrip(Hz, get_center_frequency(system)) # Eq. 1: group delay [m] = 40.3 / f² · STEC, with STEC in electrons/m² (1 TECU = 1e16). return 40.3 / f^2 * stec * 1.0e16 end @@ -192,9 +192,12 @@ measured clockwise from North. The transform is taken precomputed so it can be b once per user position and reused across satellites. """ function _elevation_azimuth(enu_from_ecef::ENUfromECEF, sat_position) - enu = enu_from_ecef(ECEF(sat_position)) - elevation = atan(enu.u, hypot(enu.e, enu.n)) - azimuth = atan(enu.e, enu.n) + sat_enu = get_sat_enu(enu_from_ecef, ECEF(sat_position)) + elevation = sat_enu.ϕ + # `SphericalFromCartesian` measures θ counter-clockwise from East (the ENU +x + # axis); the ionospheric models use azimuth measured clockwise from North, i.e. + # π/2 − θ. Only cos/sin of the azimuth are used downstream, so the wrap is moot. + azimuth = π / 2 - sat_enu.θ return elevation, azimuth end @@ -236,8 +239,11 @@ end # given user geodetic lat/lon and satellite elevation/azimuth [rad]. function _pierce_point(φ_u, λ_u, elevation, azimuth) ψ = π / 2 - elevation - asin(_NTCM_RE / (_NTCM_RE + _NTCM_HI) * cos(elevation)) - φ_pp = asin(sin(φ_u) * cos(ψ) + cos(φ_u) * sin(ψ) * cos(azimuth)) - λ_pp = λ_u + asin(sin(ψ) * sin(azimuth) / cos(φ_pp)) + # Clamp to asin's domain: the φ_pp argument is a unit dot-product that can + # overshoot ±1 by a rounding ulp, and the λ_pp argument genuinely diverges as + # the pierce point approaches a pole (cos(φ_pp) → 0). + φ_pp = asin(clamp(sin(φ_u) * cos(ψ) + cos(φ_u) * sin(ψ) * cos(azimuth), -1.0, 1.0)) + λ_pp = λ_u + asin(clamp(sin(ψ) * sin(azimuth) / cos(φ_pp), -1.0, 1.0)) return φ_pp, λ_pp end @@ -322,12 +328,12 @@ function ntcm_g_stec(elevation, azimuth, lla, doy, ut, a_i0, a_i1, a_i2) end # Day of year and universal time (hours) from a Galileo System Time (GST) week -# number and time of week [s]. GST epoch is 1999-08-22 00:00:00 UTC; GST is -# continuous (offset from UTC by leap seconds, ~18 s), which is negligible for -# the day-of-year / UT inputs of NTCM-G. +# number and time of week [s]. The GST epoch is taken from GNSSSignals +# (`get_system_start_time(GST())` = 1999-08-21T23:59:47 UTC); GST is continuous +# (offset from UTC by leap seconds, ~18 s), negligible for the day-of-year / UT +# inputs of NTCM-G. function _galileo_doy_and_ut(week_number, time_of_week) - total_seconds = week_number * 604800 + time_of_week - days = floor(Int, total_seconds / 86400) - ut = (total_seconds - days * 86400) / 3600 - return dayofyear(Date(1999, 8, 22) + Day(days)), ut + epoch = get_system_start_time(GST()) + t = epoch + Millisecond(round(Int, (week_number * 604800 + time_of_week) * 1000)) + return dayofyear(t), hour(t) + minute(t) / 60 + second(t) / 3600 + millisecond(t) / 3.6e6 end diff --git a/src/sat_position.jl b/src/sat_position.jl index e0d81ed..558111b 100755 --- a/src/sat_position.jl +++ b/src/sat_position.jl @@ -1,3 +1,39 @@ +""" + orbital_elements(data, μ, t_k) -> (; A, sqrt_A, A_dot, n, Ω_dot) + +The Keplerian elements that differ between the directly-broadcast ephemerides +(GPS LNAV `GPSL1CAData`, Galileo I/NAV `GalileoE1BData`) and the quasi-Keplerian +GPS CNAV/CNAV-2 ephemerides (`GPSCNAVData` for L5 and L2C, `GPSL1C_DData` for +L1C), evaluated at the time-from-ephemeris `t_k` (seconds): + +- `A`: semi-major axis at `t_0e` (m) +- `sqrt_A`: its square root (m^½); the broadcast `sqrt_A` for the directly-broadcast + Keplerian case (no round-trip), `√A` for CNAV/CNAV-2 (which carry no `sqrt_A` field) +- `A_dot`: its rate (m/s; `0` for the directly-broadcast Keplerian case) +- `n`: corrected mean motion at `t_k` (rad/s) +- `Ω_dot`: rate of right ascension (rad/s) + +Everything else the propagator needs (`e`, `ω`, `i_0`, `i_dot`, `M_0`, the `C_*` +harmonic coefficients, `t_0e`) is named identically across all four nav messages +and read from `data` directly. CNAV recovers `A` from `A_REF + ΔA` (with `Ȧ·t_k` +added for the radius), the mean motion from `Δn_0 (+ ½ Δṅ_0 t_k)`, and `Ω̇` from +`Ω̇_REF + ΔΩ̇`. +""" +function orbital_elements(data::GNSSDecoder.AbstractGNSSData, μ, t_k) + (A = data.sqrt_A^2, sqrt_A = data.sqrt_A, A_dot = 0.0, n = sqrt(μ) / data.sqrt_A^3 + data.Δn, Ω_dot = data.Ω_dot) +end +function orbital_elements(data::GPSModernNavData, μ, t_k) + # Quasi-Keplerian reference values from the CNAV user algorithm (IS-GPS-200N; + # identical in IS-GPS-705J and IS-GPS-800J); the broadcast fields are deltas off + # these. + A_REF = 26_559_710.0 # m + Ω_dot_REF = -2.6e-9 * π # rad/s (-2.6e-9 semicircles/s) + A = A_REF + data.ΔA + n = sqrt(μ / A^3) + data.Δn_0 + 0.5 * data.Δn_0_dot * t_k + Ω_dot = Ω_dot_REF + data.ΔΩ_dot + (A = A, sqrt_A = sqrt(A), A_dot = data.A_dot, n = n, Ω_dot = Ω_dot) +end + function calc_eccentric_anomaly(mean_anomaly, eccentricity) Ek = mean_anomaly for k = 1:30 @@ -12,10 +48,9 @@ end function calc_eccentric_anomaly(decoder::GNSSDecoder.GNSSDecoderState, t) data = decoder.data - computed_mean_motion = sqrt(decoder.constants.μ) / data.sqrt_A^3 time_from_ephemeris_reference_epoch = correct_week_crossovers(t - data.t_0e) - corrected_mean_motion = computed_mean_motion + data.Δn - mean_anomaly = data.M_0 + corrected_mean_motion * time_from_ephemeris_reference_epoch + el = orbital_elements(data, decoder.constants.μ, time_from_ephemeris_reference_epoch) + mean_anomaly = data.M_0 + el.n * time_from_ephemeris_reference_epoch calc_eccentric_anomaly(mean_anomaly, data.e) end @@ -62,10 +97,12 @@ coordinates (meters and m/s respectively). function calc_satellite_position_and_velocity(decoder::GNSSDecoder.GNSSDecoderState, t) data = decoder.data constants = decoder.constants - semi_major_axis = data.sqrt_A^2 time_from_ephemeris_reference_epoch = correct_week_crossovers(t - data.t_0e) - computed_mean_motion = sqrt(decoder.constants.μ) / data.sqrt_A^3 - corrected_mean_motion = computed_mean_motion + data.Δn + el = orbital_elements(data, constants.μ, time_from_ephemeris_reference_epoch) + # Semi-major axis at t_k: constant for LNAV/Galileo (A_dot = 0), `A_0 + Ȧ·t_k` + # for CNAV/CNAV-2. + semi_major_axis = el.A + el.A_dot * time_from_ephemeris_reference_epoch + corrected_mean_motion = el.n eccentric_anomaly = calc_eccentric_anomaly(decoder, t) eccentric_anomaly_dot = corrected_mean_motion / (1.0 - data.e * cos(eccentric_anomaly)) β = data.e / (1 + sqrt(1 - data.e^2)) @@ -102,6 +139,7 @@ function calc_satellite_position_and_velocity(decoder::GNSSDecoder.GNSSDecoderSt ) * true_anomaly_dot corrected_radius_dot = + el.A_dot * (1.0 - data.e * cos(eccentric_anomaly)) + semi_major_axis * data.e * sin(eccentric_anomaly) * corrected_mean_motion / (1.0 - data.e * cos(eccentric_anomaly)) + 2 * @@ -130,10 +168,10 @@ function calc_satellite_position_and_velocity(decoder::GNSSDecoder.GNSSDecoderSt x_position_in_orbital_plane * corrected_argument_of_latitude_dot corrected_longitude_of_ascending_node = - data.Ω_0 + (data.Ω_dot - constants.Ω_dot_e) * time_from_ephemeris_reference_epoch - + data.Ω_0 + (el.Ω_dot - constants.Ω_dot_e) * time_from_ephemeris_reference_epoch - constants.Ω_dot_e * data.t_0e - corrected_longitude_of_ascending_node_dot = data.Ω_dot - constants.Ω_dot_e + corrected_longitude_of_ascending_node_dot = el.Ω_dot - constants.Ω_dot_e position = SVector( x_position_in_orbital_plane * cos(corrected_longitude_of_ascending_node) - diff --git a/src/sat_time.jl b/src/sat_time.jl index b411c5d..c2d4078 100644 --- a/src/sat_time.jl +++ b/src/sat_time.jl @@ -3,31 +3,51 @@ function correct_week_crossovers(t) t + (t > half_week ? -2 * half_week : (t < -half_week ? 2 * half_week : 0.0)) end +# Time of week (seconds) at the navigation-frame reference epoch. GPS LNAV, GPS +# CNAV (L5) and Galileo broadcast it directly as TOW; GPS CNAV-2 (L1C) instead +# broadcasts the two-hour interval count ITOW and the 18 s time-of-interval TOI +# (IS-GPS-800J §3.5.3), so reconstruct TOW = ITOW·7200 + TOI·18. +get_tow(decoder::GNSSDecoder.GNSSDecoderState) = decoder.data.TOW +get_tow(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1C_DData}) = + decoder.data.ITOW * 7200 + decoder.data.toi * 18 + function calc_uncorrected_time(state::SatelliteState) system = state.system - t_tow = state.decoder.data.TOW + t_tow = get_tow(state.decoder) + # Bit-count term uses the decoder's nav-message symbol rate — the rate the bits + # were counted at — reported by `get_data_frequency` on the decoder + # state (GNSSDecoder 3.6). It is the decoder's *data* signal, not the tracked + # `system`: with joint same-band tracking the pseudorange may be generated on a + # dataless pilot (e.g. GPS L1C-P, Galileo E1C) whose own data frequency is 0 Hz, + # while the bits come from the data component the decoder ran on. The code- and + # carrier-phase terms use the tracked ranging `system`. t_bits = state.decoder.num_bits_after_valid_syncro_sequence / - GNSSSignals.get_data_frequency(system) * Hz - t_code_phase = state.code_phase / GNSSSignals.get_code_frequency(system) * Hz - t_carrier_phase = state.carrier_phase / GNSSSignals.get_center_frequency(system) * Hz + get_data_frequency(state.decoder) * Hz + t_code_phase = state.code_phase / get_code_frequency(system) * Hz + t_carrier_phase = state.carrier_phase / get_center_frequency(system) * Hz t_tow + t_bits + t_code_phase + t_carrier_phase end function calc_relativistic_correction(decoder::GNSSDecoder.GNSSDecoderState, t) + data = decoder.data + time_from_ephemeris_reference_epoch = correct_week_crossovers(t - data.t_0e) + # √A from the effective elements: the broadcast `sqrt_A` directly for LNAV/Galileo, + # `√(A_REF + ΔA)` for CNAV/CNAV-2 (which carry no `sqrt_A` field). + el = orbital_elements(data, decoder.constants.μ, time_from_ephemeris_reference_epoch) E = calc_eccentric_anomaly(decoder, t) - decoder.constants.F * decoder.data.e * decoder.data.sqrt_A * sin(E) + decoder.constants.F * data.e * el.sqrt_A * sin(E) end -function correct_clock(decoder::GNSSDecoder.GNSSDecoderState, t) +function correct_clock(decoder::GNSSDecoder.GNSSDecoderState, system, t) Δtr = calc_relativistic_correction(decoder, t) Δt = decoder.data.a_f0 + decoder.data.a_f1 * (t - decoder.data.t_0c) + decoder.data.a_f2 * (t - decoder.data.t_0c)^2 + Δtr - t - correct_by_group_delay(decoder, Δt) + t - correct_by_group_delay(decoder, system, Δt) end function calc_satellite_clock_drift(decoder::GNSSDecoder.GNSSDecoderState, t) @@ -35,21 +55,126 @@ function calc_satellite_clock_drift(decoder::GNSSDecoder.GNSSDecoderState, t) decoder.data.a_f2 * t * 2 end -function correct_by_group_delay( +# Group-delay / inter-signal correction, selected by the *ranging* signal `system` +# while the values come from the `decoder`'s navigation message. The two can differ: +# with joint same-band tracking a band's pseudorange may be generated on a pilot or +# secondary signal (e.g. GPS L1C-P) while the ephemeris/clock are decoded from the +# data component (L1C-D). For GPS this selects the right ISC per signal +# (IS-GPS-705J §20.3.3.3.1.2 / IS-GPS-800J §3.5.4.1); for Galileo the broadcast group +# delay is per band, so the decoder's message alone determines it. + +# GPS L1 C/A via LNAV: only T_GD (LNAV carries no ISCs). +correct_by_group_delay( decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1CAData}, + ::GNSSSignals.GPSL1CA, t, -) - t - decoder.data.T_GD -end +) = t - decoder.data.T_GD + +# The CNAV / CNAV-2 group-delay terms are ~metre-level inter-signal corrections a +# satellite may not have broadcast yet when its ephemeris and clock are already +# complete, so any of them can be `nothing` on a usable decoder — apply each when +# present, treat a missing one as zero, never throw. +# +# T_GD is a single per-SV group-delay differential (L1 P(Y)−L2 P(Y), IS-GPS-200 +# §20.3.3.3.3.2): the same value on every GPS signal that carries it — only the ISC +# differs per signal. A missing T_GD could therefore be sourced from another GPS +# decoder for the same PRN, ideally an L1 one where it is broadcast far more often +# (L1 C/A every 30 s subframe, L1C every 18 s subframe 2, versus up to 288 s / 144 s +# on L2C / L5). Decoders are independent per (signal, PRN) here, so for now a missing +# term is simply taken as zero — a ~metre bias, never a blocked fix. +group_delay_term(x) = something(x, 0.0) + +# GPS CNAV rides on one shared data container for L5 and L2C; the ISC is picked by +# the ranging signal — L5 I5 for an L5 range, L2C for an L2C-M range +# (IS-GPS-705J §20.3.3.3.1.2 / IS-GPS-200N §30.3.3.3.1.1). -T_GD + ISC. +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSCNAVData}, + ::GNSSSignals.GPSL5I, + t, +) = t - group_delay_term(decoder.data.T_GD) + group_delay_term(decoder.data.ISC_L5I5) +# The dataless pilots range off the same CNAV message: the L5 pilot has its own +# ISC_L5Q5, while L2 CL shares the single broadcast ISC_L2C with CM (IS-GPS-200N +# §30.3.3.3.1.1 defines one correction for the L2C signal). +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSCNAVData}, + ::GNSSSignals.GPSL5Q, + t, +) = t - group_delay_term(decoder.data.T_GD) + group_delay_term(decoder.data.ISC_L5Q5) +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSCNAVData}, + ::Union{GNSSSignals.GPSL2CM,GNSSSignals.GPSL2CL}, + t, +) = t - group_delay_term(decoder.data.T_GD) + group_delay_term(decoder.data.ISC_L2C) + +# GPS CNAV-2 (L1C) carries the full L1 ISC set, so one decoder serves a range +# generated on the L1C data (L1C-D), the L1C pilot (L1C-P) or C/A — pick the ISC of +# the signal the range was actually generated on. +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1C_DData}, + ::GNSSSignals.GPSL1C_D, + t, +) = t - group_delay_term(decoder.data.T_GD) + group_delay_term(decoder.data.ISC_L1CD) +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1C_DData}, + ::GNSSSignals.GPSL1C_P, + t, +) = t - group_delay_term(decoder.data.T_GD) + group_delay_term(decoder.data.ISC_L1CP) +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GPSL1C_DData}, + ::GNSSSignals.GPSL1CA, + t, +) = t - group_delay_term(decoder.data.T_GD) + group_delay_term(decoder.data.ISC_L1CA) -function correct_by_group_delay( +# Galileo: the broadcast group delay is per band (E1 vs E5a), so it depends only on +# the decoder's message, not on whether the range came from the data or the pilot +# component — E1B/E1C share BGD(E1,E5b); E5a-I/E5a-Q share BGD(E1,E5a). +correct_by_group_delay( decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GalileoE1BData}, + ::AbstractGNSSSignal, t, -) - t - decoder.data.broadcast_group_delay_e1_e5b -end +) = t - decoder.data.broadcast_group_delay_e1_e5b +correct_by_group_delay( + decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.GalileoE5aData}, + ::AbstractGNSSSignal, + t, +) = t - decoder.data.broadcast_group_delay_e1_e5a function calc_corrected_time(state::SatelliteState) approximated_time = calc_uncorrected_time(state) - correct_clock(state.decoder, approximated_time) -end \ No newline at end of file + correct_clock(state.decoder, state.system, approximated_time) +end + +""" + ggto_available(decoder) -> Bool + +Return `true` if `decoder` carries a complete Galileo–GPS Time Offset (GGTO) +record (Galileo word type 10: `A_0G`, `A_1G`, `t_0G`, `WN_0G`). The GGTO lets +the receiver express Galileo System Time in GPS time, which makes it possible to +combine GPS and Galileo satellites when the geometry is too weak to estimate an +independent Galileo clock bias. Always `false` for non-Galileo systems. +""" +ggto_available(::GNSSDecoder.GNSSDecoderState) = false +function ggto_available(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.AbstractGalileoData}) + data = decoder.data + !isnothing(data.A_0G) && + !isnothing(data.A_1G) && + !isnothing(data.t_0G) && + !isnothing(data.WN_0G) +end + +""" + calc_ggto_offset(decoder, t) -> Float64 + +Galileo–GPS Time Offset `Δt_systems = GST − GPST` in seconds at Galileo time of +week `t`, per the Galileo OS SIS ICD (word type 10): + + Δt_systems = A_0G + A_1G · (t − t_0G + 604800 · ((WN − WN_0G) mod 64)) + +`WN_0G` is the 6-bit GGTO reference week, so the week difference is taken modulo +64. To convert a Galileo system time to GPS time, subtract this offset. +""" +function calc_ggto_offset(decoder::GNSSDecoder.GNSSDecoderState{<:GNSSDecoder.AbstractGalileoData}, t) + data = decoder.data + Δweek = mod(data.WN - data.WN_0G, 64) + data.A_0G + data.A_1G * (t - data.t_0G + 604800 * Δweek) +end diff --git a/src/user_position.jl b/src/user_position.jl index f649b59..5c3b532 100755 --- a/src/user_position.jl +++ b/src/user_position.jl @@ -1,20 +1,27 @@ """ -Computes ̂ρ, the distance between the satellite and the estimated user position +Computes ̂ρ, the distance between the satellite and the estimated user position $SIGNATURES -`ξ`: Combination of estimated user position and time correction +`ξ`: Estimated user position, one clock correction per GNSS time system, and one + receiver inter-frequency bias per frequency band beyond the reference band, i.e. + `[x, y, z, tc₁, …, tc_num_clock_biases, ifb₁, …, ifb_num_ifb]` `sat_positions`: Satellite Positions +`bias_columns`: The per-satellite [`BiasColumns`](@ref) (clock column and + inter-frequency-bias column of each satellite). Known range corrections (atmosphere, + GGTO offset) are already folded into the measured `ρ` by [`calc_pvt`](@ref). """ -function calc_ρ_hat!(ρ, sat_positions, ξ) +function calc_ρ_hat!(ρ, sat_positions, ξ, bias_columns::BiasColumns) rₙ = SVector{3}(ξ[1], ξ[2], ξ[3]) - tc = ξ[4] - n = size(sat_positions, 2) - for j in 1:n + num_sats = size(sat_positions, 2) + for j in 1:num_sats sat_pos = SVector{3}(sat_positions[1, j], sat_positions[2, j], sat_positions[3, j]) travel_time = norm(sat_pos - rₙ) / SPEEDOFLIGHT rotated_sat_pos = rotate_by_earth_rotation(sat_pos, travel_time) - ρ[j] = norm(rotated_sat_pos - rₙ) + tc + ifb = + bias_columns.ifb_indices[j] == 0 ? 0.0 : + ξ[3+bias_columns.num_clock_biases+bias_columns.ifb_indices[j]] + ρ[j] = norm(rotated_sat_pos - rₙ) + ξ[3+bias_columns.clock_bias_indices[j]] + ifb end return ρ end @@ -47,26 +54,38 @@ end """ Computes Geometry Matrix H +The matrix has [`num_lsq_params`](@ref)`(layout)` columns: three for the position +partials, one per GNSS time system, and one per frequency band beyond the reference +band. Each row carries a `1.0` in its system's clock column (`3 + clock_bias_indices[j]`) +and, unless the satellite is on the reference band, a `1.0` in its band's +inter-frequency-bias column (`3 + num_clock_biases + ifb_indices[j]`); the remaining +bias columns are zero. $SIGNATURES -`ξ`: Combination of estimated user position and time correction +`ξ`: Estimated user position, per-system clock corrections, and per-band inter-frequency biases `sat_positions`: Matrix of satellite positions +`bias_columns`: The per-satellite [`BiasColumns`](@ref) """ -function calc_H!(H, sat_positions, ξ) - n = size(sat_positions, 2) - for j in 1:n +function calc_H!(H, sat_positions, ξ, bias_columns::BiasColumns) + num_sats = size(sat_positions, 2) + fill!(H, 0.0) + for j in 1:num_sats sat_pos = SVector{3}(view(sat_positions, :, j)) e = calc_e(sat_pos, ξ) H[j, 1] = e[1] H[j, 2] = e[2] H[j, 3] = e[3] - H[j, 4] = 1.0 + H[j, 3+bias_columns.clock_bias_indices[j]] = 1.0 + if bias_columns.ifb_indices[j] != 0 + H[j, 3+bias_columns.num_clock_biases+bias_columns.ifb_indices[j]] = 1.0 + end end return H end -calc_H(sat_positions, ξ) = - calc_H!(Matrix{Float64}(undef, size(sat_positions, 2), 4), sat_positions, ξ) +calc_H(sat_positions, ξ, bias_columns::BiasColumns) = + calc_H!(Matrix{Float64}(undef, size(sat_positions, 2), num_lsq_params(bias_columns)), + sat_positions, ξ, bias_columns) """ Computes the directional second derivative of `calc_ρ_hat` along `v`, @@ -85,8 +104,8 @@ function calc_Avv!(dir_deriv, sat_positions, ξ, v) rₙ = SVector{3}(ξ[1], ξ[2], ξ[3]) v_r = SVector{3}(v[1], v[2], v[3]) v_r_sq = dot(v_r, v_r) - n = size(sat_positions, 2) - for j in 1:n + num_sats = size(sat_positions, 2) + for j in 1:num_sats sat_pos = SVector{3}(sat_positions[1, j], sat_positions[2, j], sat_positions[3, j]) travel_time = norm(sat_pos - rₙ) / SPEEDOFLIGHT rotated_sat_pos = rotate_by_earth_rotation(sat_pos, travel_time) @@ -101,26 +120,56 @@ end """ Calculates the dilution of precision for a given geometry matrix H +`H_GEO` has `3 + num_clock_biases + num_ifb` columns (three position partials, one per +GNSS time system, and one per frequency band beyond the reference). `D = (HᵀH)⁻¹` is +the parameter covariance in the units/frame of `H`. Because `calc_e` builds `H` from +ECEF line-of-sight vectors, the position block of `D` is in ECEF; it is rotated into +the local ENU frame at `user_pos` before the horizontal/vertical split, so `HDOP`/`VDOP` +are taken in the user's tangent plane (`PDOP`/`GDOP` are trace-invariant and unaffected +by the rotation). `GDOP` spans all parameters; `TDOP` reports the clock variance of the +primary (reference) system — see [`PVTSolution`](@ref) — while the other systems' clock +(inter-system-bias) and the inter-frequency-bias variances enter `GDOP` only. + +A rank-deficient geometry makes `HᵀH` singular (not positive definite); a +non-throwing Cholesky detects this and the function returns the sentinel +`DOP(-1, …)` instead of erroring. + $SIGNATURES `H_GEO`: Geometry matrix +`user_pos`: User ECEF position, for the ECEF→ENU rotation of the horizontal/vertical DOPs +`primary_clock_index`: Index (1…num_clock_biases) of the clock column whose variance is reported as TDOP """ -function calc_DOP(H_GEO) - D = inv(SMatrix{4,4}(H_GEO' * H_GEO)) - if D[4, 4] < 0 || - D[3, 3] < 0 || - D[1, 1] + D[2, 2] < 0 || - D[1, 1] + D[2, 2] + D[3, 3] < 0 || - sum(diag(D)) < 0 - # Something has gone wrong - # This could probably be detected somewhere else - # more efficiently. - return DOP(-1, -1, -1, -1, -1) - end - TDOP = sqrt(D[4, 4]) # temporal dop - VDOP = sqrt(D[3, 3]) # vertical dop - HDOP = sqrt(D[1, 1] + D[2, 2]) # horizontal dop - PDOP = sqrt(D[1, 1] + D[2, 2] + D[3, 3]) # position dop - GDOP = sqrt(sum(diag(D))) # geometrical dop +function calc_DOP(H_GEO, user_pos::ECEF, primary_clock_index = 1) + # HᵀH is symmetric positive definite iff H has full column rank. Cholesky is + # the right factorization for an SPD matrix (≈2× cheaper than a general or + # symmetric-indefinite inverse, and numerically faithful), and `check = false` + # lets a rank-deficient (singular) geometry fail gracefully via `issuccess` + # instead of throwing. The inverse of an SPD matrix is itself SPD, so the DOP + # variances on the diagonal are then guaranteed non-negative. + F = cholesky(Symmetric(H_GEO' * H_GEO); check = false) + issuccess(F) || return DOP(-1, -1, -1, -1, -1) + D = inv(F) + + # Rotate the ECEF position covariance into the local ENU (East, North, Up) + # frame so the horizontal/vertical split is taken in the user's tangent plane. + # `R` is the ECEF→ENU rotation at the user position; it matches Geodesy's + # `ENUfromECEF` convention (verified equal), built explicitly here as it is + # marginally cheaper than recovering it from the transform. + lla = LLAfromECEF(wgs84)(user_pos) + sφ, cφ = sincosd(lla.lat) + sλ, cλ = sincosd(lla.lon) + R = @SMatrix [ + -sλ cλ 0.0 + -sφ*cλ -sφ*sλ cφ + cφ*cλ cφ*sλ sφ + ] + D_enu = R * SMatrix{3,3}(@view D[1:3, 1:3]) * R' + + HDOP = sqrt(D_enu[1, 1] + D_enu[2, 2]) # horizontal dop (East² + North²) + VDOP = sqrt(D_enu[3, 3]) # vertical dop (Up) + PDOP = sqrt(D[1, 1] + D[2, 2] + D[3, 3]) # position dop (trace-invariant) + TDOP = sqrt(D[3+primary_clock_index, 3+primary_clock_index]) # temporal dop (reference system) + GDOP = sqrt(tr(D)) # geometrical dop (all parameters) return DOP(GDOP, PDOP, VDOP, HDOP, TDOP) end @@ -129,36 +178,42 @@ end Computes user position $SIGNATURES -sat_positions: Array of satellite positions. Needs 3 values per satellite (xyz), size must be (3, N)") -`ρ`: Array of pseudo ranges +`sat_positions_mat`: Satellite positions as a `(3, N)` matrix (xyz per satellite). +`ρ`: Array of pseudo ranges + +Calculates the user position by least squares method. The algorithm is based on the common reception method. + +`bias_columns`: The per-satellite [`BiasColumns`](@ref) (clock and inter-frequency-bias columns). -Calculates the user position by least squares method. The algorithm is based on the common reception method. +Returns `(ξ, residuals)`: the solved state vector +`ξ = [x, y, z, tc₁, …, ifb₁, …]` and the per-satellite post-fit residual vector +(modeled minus measured pseudorange, metres), in the same satellite order as `ρ`. """ -function user_position(sat_positions, ρ, prev_ξ = zeros(4)) - sat_positions_mat = reduce(hcat, sat_positions) +function user_position(sat_positions_mat, ρ, bias_columns::BiasColumns, prev_ξ = zeros(num_lsq_params(bias_columns))) + model! = (out, x, par) -> calc_ρ_hat!(out, x, par, bias_columns) + jacobian! = (J, x, par) -> calc_H!(J, x, par, bias_columns) # Geodesic acceleration helps when starting far from the optimum (cold start # from origin, ~6e6 m away) by trading per-iteration work for fewer iterations. # When prev_ξ is already near-converged, the extra Avv! evals are pure overhead. - # Detect cold by checking the default zeros(4) sentinel. + # Detect cold by checking the default zeros sentinel (origin position). ξ_fit_ols = if iszero(prev_ξ) curve_fit( - calc_ρ_hat!, calc_H!, sat_positions_mat, ρ, collect(prev_ξ); + model!, jacobian!, sat_positions_mat, ρ, collect(prev_ξ); inplace = true, - avv! = (dir_deriv, p, v) -> calc_Avv!(dir_deriv, sat_positions_mat, p, v), + avv! = (dir_deriv, par, v) -> calc_Avv!(dir_deriv, sat_positions_mat, par, v), lambda = 0.0, min_step_quality = 0.0, ) else curve_fit( - calc_ρ_hat!, calc_H!, sat_positions_mat, ρ, collect(prev_ξ); + model!, jacobian!, sat_positions_mat, ρ, collect(prev_ξ); inplace = true, ) end # wt = 1 ./ (ξ_fit_ols.resid .^ 2) # ξ_fit_wls = curve_fit(ρ_hat, H, sat_positions_mat, ρ, wt, collect(prev_ξ)) - rmse = sqrt(mean(ξ_fit_ols.resid .^ 2)) - return ξ_fit_ols.param, rmse + return ξ_fit_ols.param, ξ_fit_ols.resid end """ @@ -166,31 +221,43 @@ Computes user velocity $SIGNATURES -Calculates the user velocity and clock drift +Calculates the user velocity and a single receiver clock drift, returned as +`[vx, vy, vz, ċ]`. Unlike the position solve — which estimates one clock *bias* +per GNSS time system (the inter-system bias / GGTO is metre-level and must be +resolved) — a single clock *drift* is shared by all satellites regardless of +GNSS: the receiver has one oscillator, and the only per-system difference is the +drift of the inter-system time offset (e.g. the GGTO rate `A_1G`), which is +~1e-6 m/s — far below the Doppler velocity resolution. Using one common drift lets +every satellite constrain the four unknowns instead of spending a column per +system. """ -function calc_user_velocity_and_clock_drift(sat_positions_and_velocities, ξ, states, times, H) - center_frequency = get_center_frequency(first(states).system) - λ = SPEEDOFLIGHT / upreferred(center_frequency / Hz) - n = length(states) - y = Vector{Float64}(undef, n) - for j in 1:n +function calc_user_velocity_and_clock_drift(sat_positions_and_velocities, states, times, H) + num_sats = length(states) + # Normal-equations form of the 4-unknown velocity + clock-drift least squares. + # The velocity design row is [eₓ e_y e_z 1]: the line-of-sight unit vector (H's + # first three columns) plus the single common clock-drift column (H's per-system + # clock columns, unit indicators, collapsed to a 1). Accumulating HᵀH (4×4) and + # Hᵀy (length 4) row by row keeps the (num_sats × 4) design matrix unmaterialised + # and the solve a fixed 4×4 regardless of satellite count — no per-count + # recompilation and no per-epoch heap allocation. The Doppler wavelength is + # evaluated per satellite from its own carrier frequency. + HtH = zero(SMatrix{4,4,Float64}) + Hty = zero(SVector{4,Float64}) + for j in 1:num_sats state = states[j] sat_pv = sat_positions_and_velocities[j] + λ = SPEEDOFLIGHT / upreferred(get_center_frequency(state.system) / Hz) doppler = upreferred(state.carrier_doppler / Hz) clock_drift = calc_satellite_clock_drift(state.decoder, times[j]) - e = calc_e(get_sat_position(sat_pv), ξ) - y[j] = -(doppler * λ - clock_drift * SPEEDOFLIGHT - dot(e, get_sat_velocity(sat_pv))) - end - _solve_velocity_qr(Val(n), H, y) -end - -@generated function _solve_velocity_qr(::Val{n}, H, y) where {n} - quote - Hs = SMatrix{$n, 4}(H) - ys = SVector{$n}(y) - F = qr(Hs) - SMatrix{4,4}(F.R) \ (F.Q' * ys) + # Line-of-sight unit vector, already computed for the position solve and + # stored in H's first three columns (calc_H) — no need to recompute calc_e. + e = SVector{3}(view(H, j, 1:3)) + a = SVector(e[1], e[2], e[3], 1.0) + yⱼ = -(doppler * λ - clock_drift * SPEEDOFLIGHT - dot(e, get_sat_velocity(sat_pv))) + HtH += a * a' + Hty += a * yⱼ end + HtH \ Hty end get_sat_position(x) = x.position diff --git a/test/cnav.jl b/test/cnav.jl new file mode 100644 index 0000000..5d484e6 --- /dev/null +++ b/test/cnav.jl @@ -0,0 +1,153 @@ +# GPS CNAV (L5, GPSCNAVData) and CNAV-2 (L1C-D, GPSL1C_DData) support. +# +# No real CNAV capture is available, so the orbit is validated by self-consistency +# (Q2): a CNAV ephemeris with zero deltas (ΔA from A_REF, no rates, ΔΩ_dot relative +# to Ω̇_REF) must reproduce the directly-broadcast Keplerian (LNAV) result exactly, +# and the analytic velocity must match a finite difference of the position. +@testset "CNAV (GPS L5 / L1C-D)" begin + A_REF = 26_559_710.0 + Ωdot_REF = -2.6e-9 * π + + l5 = GNSSDecoder.GPSL5IDecoderState(1) + l1cd = GNSSDecoder.GPSL1C_DDecoderState(1) + swap(state, d) = GNSSDecoder.GNSSDecoderState(state; data = d, raw_data = d) + + @testset "classification" begin + @test GNSSSignals.get_time_system(GNSSSignals.GPSL5I()) == GNSSSignals.GPST() + @test GNSSSignals.get_time_system(GNSSSignals.GPSL1C_D()) == GNSSSignals.GPST() + @test !PositionVelocityTime.ggto_available(l5) + @test !PositionVelocityTime.ggto_available(l1cd) + end + + @testset "get_week uses full broadcast WN (no rollover)" begin + # approximate_year must be ignored for CNAV (full 13-bit WN). + @test PositionVelocityTime.get_week(swap(l5, GNSSDecoder.GPSCNAVData(; WN = 2200)); approximate_year = 2042) == + 2200 + @test PositionVelocityTime.get_week(swap(l1cd, GNSSDecoder.GPSL1C_DData(; WN = 2200)); approximate_year = 2010) == + 2200 + end + + @testset "Klobuchar params from CNAV α_0…β_3" begin + for (state, T) in ((l5, GNSSDecoder.GPSCNAVData), (l1cd, GNSSDecoder.GPSL1C_DData)) + d = T(; α_0 = 1e-8, α_1 = 2e-8, α_2 = 3e-8, α_3 = 4e-8, + β_0 = 1e4, β_1 = 2e4, β_2 = 3e4, β_3 = 4e4) + kp = PositionVelocityTime.klobuchar_params(swap(state, d)) + @test kp isa PositionVelocityTime.KlobucharParams + @test kp.α_0 == 1e-8 + @test kp.β_3 == 4e4 + end + # Partially populated → nothing, not an error. + @test PositionVelocityTime.klobuchar_params(swap(l5, GNSSDecoder.GPSCNAVData(; α_0 = 1e-8))) === nothing + end + + # Shared Keplerian element set (realistic GPS MEO). + kep = (; M_0 = 0.3, e = 0.012, sqrt_A = 5153.65, Ω_0 = 1.0, i_0 = 0.96, ω = -0.5, + Δn = 4.7e-9, Ω_dot = -8.1e-9, i_dot = 1.3e-10, C_uc = 1e-6, C_us = 2e-6, + C_rc = 200.0, C_rs = 30.0, C_ic = -1e-7, C_is = 9e-8, t_0e = 300.0) + lnav = GNSSDecoder.GPSL1CAData(; M_0 = kep.M_0, e = kep.e, sqrt_A = kep.sqrt_A, Ω_0 = kep.Ω_0, + i_0 = kep.i_0, ω = kep.ω, Δn = kep.Δn, Ω_dot = kep.Ω_dot, i_dot = kep.i_dot, + C_uc = kep.C_uc, C_us = kep.C_us, C_rc = kep.C_rc, C_rs = kep.C_rs, + C_ic = kep.C_ic, C_is = kep.C_is, t_0e = kep.t_0e) + + @testset "CNAV orbit reduces to the equivalent Keplerian ($T)" for T in + (GNSSDecoder.GPSCNAVData, GNSSDecoder.GPSL1C_DData) + cnav = T(; M_0 = kep.M_0, e = kep.e, ΔA = kep.sqrt_A^2 - A_REF, A_dot = 0.0, + Δn_0 = kep.Δn, Δn_0_dot = 0.0, Ω_0 = kep.Ω_0, i_0 = kep.i_0, ω = kep.ω, + ΔΩ_dot = kep.Ω_dot - Ωdot_REF, i_dot = kep.i_dot, C_uc = kep.C_uc, + C_us = kep.C_us, C_rc = kep.C_rc, C_rs = kep.C_rs, C_ic = kep.C_ic, + C_is = kep.C_is, t_0e = kep.t_0e) + dl = swap(GNSSDecoder.GPSL1CADecoderState(1), lnav) + ref_state = T === GNSSDecoder.GPSCNAVData ? l5 : l1cd + dc = swap(ref_state, cnav) + for t in (300.0, 1500.0, 4000.0) + pl = PositionVelocityTime.calc_satellite_position_and_velocity(dl, t) + pc = PositionVelocityTime.calc_satellite_position_and_velocity(dc, t) + @test pc.position ≈ pl.position atol = 1e-6 + @test pc.velocity ≈ pl.velocity atol = 1e-9 + end + end + + @testset "CNAV velocity matches position finite difference (A_dot, Δn_0_dot)" begin + cnav = GNSSDecoder.GPSCNAVData(; M_0 = 0.3, e = 0.012, ΔA = 1234.5, A_dot = 0.05, + Δn_0 = 4.7e-9, Δn_0_dot = 2e-14, Ω_0 = 1.0, i_0 = 0.96, ω = -0.5, + ΔΩ_dot = -8.1e-9 - Ωdot_REF, i_dot = 1.3e-10, C_uc = 1e-6, C_us = 2e-6, + C_rc = 200.0, C_rs = 30.0, C_ic = -1e-7, C_is = 9e-8, t_0e = 300.0) + dc = swap(l5, cnav) + for t in (600.0, 2400.0) + pv = PositionVelocityTime.calc_satellite_position_and_velocity(dc, t) + h = 0.5 + fd = + (PositionVelocityTime.calc_satellite_position(dc, t + h) .- PositionVelocityTime.calc_satellite_position(dc, t - h)) ./ + (2h) + @test pv.velocity ≈ fd atol = 1e-3 + end + end + + # Group delay is selected by the *ranging* signal, with values read from the + # CNAV-2 message: one L1C-D decoder serves a range generated on the L1C data, + # the L1C pilot, or C/A. This decouples ranging signal from nav source — a + # jointly-tracked L1 band may range on the pilot while decoding L1C-D. + @testset "group delay selected by ranging signal (CNAV-2 ISC_L1CD/L1CP/L1CA)" begin + d = GNSSDecoder.GPSL1C_DData(; T_GD = 5.0e-9, ISC_L1CD = 1.0e-9, ISC_L1CP = 2.0e-9, + ISC_L1CA = 3.0e-9) + dc = swap(l1cd, d) + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL1C_D(), 100.0) ≈ + 100.0 - 5.0e-9 + 1.0e-9 + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL1C_P(), 100.0) ≈ + 100.0 - 5.0e-9 + 2.0e-9 + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL1CA(), 100.0) ≈ + 100.0 - 5.0e-9 + 3.0e-9 + end + + # CNAV likewise serves the dataless pilots: a range generated on the L5 pilot (Q5) + # picks its own ISC_L5Q5, while L2 CL shares the single broadcast ISC_L2C with CM. + @testset "group delay selected by ranging signal (CNAV ISC_L5I5/L5Q5/L2C)" begin + d = GNSSDecoder.GPSCNAVData(; T_GD = 5.0e-9, ISC_L5I5 = 1.0e-9, ISC_L5Q5 = 2.0e-9, + ISC_L2C = 3.0e-9) + dc = swap(l5, d) + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL5I(), 100.0) ≈ + 100.0 - 5.0e-9 + 1.0e-9 + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL5Q(), 100.0) ≈ + 100.0 - 5.0e-9 + 2.0e-9 + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL2CM(), 100.0) ≈ + 100.0 - 5.0e-9 + 3.0e-9 + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL2CL(), 100.0) ≈ + 100.0 - 5.0e-9 + 3.0e-9 + end + + # T_GD and the ISCs are ~metre-level inter-signal corrections a satellite may not + # have broadcast yet when its ephemeris and clock are already complete (T_GD/band + # ISC ride in CNAV MT30; ISC_L1CA in CNAV-2 subframe 3), so a usable decoder can + # have them `nothing`: apply when present, treat a missing one as zero, never throw. + @testset "missing group-delay terms treated as zero, not an error" begin + # CNAV (L5/L2C): both T_GD and the band ISC absent. + dc5 = swap(l5, GNSSDecoder.GPSCNAVData()) + @test PositionVelocityTime.correct_by_group_delay(dc5, GNSSSignals.GPSL5I(), 100.0) == 100.0 + @test PositionVelocityTime.correct_by_group_delay(dc5, GNSSSignals.GPSL2CM(), 100.0) == 100.0 + # CNAV: T_GD present, ISC still absent → apply only T_GD. + dc5b = swap(l5, GNSSDecoder.GPSCNAVData(; T_GD = 5.0e-9)) + @test PositionVelocityTime.correct_by_group_delay(dc5b, GNSSSignals.GPSL5I(), 100.0) ≈ 100.0 - 5.0e-9 + # CNAV-2 (L1C): all terms absent. + dc1 = swap(l1cd, GNSSDecoder.GPSL1C_DData()) + @test PositionVelocityTime.correct_by_group_delay(dc1, GNSSSignals.GPSL1C_D(), 100.0) == 100.0 + @test PositionVelocityTime.correct_by_group_delay(dc1, GNSSSignals.GPSL1C_P(), 100.0) == 100.0 + @test PositionVelocityTime.correct_by_group_delay(dc1, GNSSSignals.GPSL1CA(), 100.0) == 100.0 + end + + # A pilot ranging signal has 0 Hz data rate; the transmit-time bit-count term must + # use the decoder's data-signal rate instead, so ranging the band on the L1C pilot + # gives the same (finite) uncorrected time as ranging on the L1C data component. + @testset "pilot ranging signal: transmit time well-defined" begin + d = GNSSDecoder.GPSL1C_DData(; ITOW = 18, toi = 10) + dec = GNSSDecoder.GNSSDecoderState(l1cd; data = d, raw_data = d, + num_bits_after_valid_syncro_sequence = 50) + common = (; decoder = dec, code_phase = 123.0, + carrier_doppler = 0.0Hz, carrier_phase = 0.0) + t_data = + PositionVelocityTime.calc_uncorrected_time(SatelliteState(; system = GNSSSignals.GPSL1C_D(), common...)) + t_pilot = + PositionVelocityTime.calc_uncorrected_time(SatelliteState(; system = GNSSSignals.GPSL1C_P(), common...)) + @test isfinite(t_pilot) + @test t_pilot ≈ t_data + end +end diff --git a/test/dop.jl b/test/dop.jl new file mode 100644 index 0000000..6ef8979 --- /dev/null +++ b/test/dop.jl @@ -0,0 +1,71 @@ +@testset "calc_DOP" begin + # ECEF line-of-sight unit vector for a satellite at azimuth `az` (rad, from + # North) and elevation `el` (rad), seen from `user`. Built by rotating the + # local ENU direction back to ECEF via Geodesy, so the geometry matches what + # the solver feeds calc_DOP (ECEF line-of-sight rows from calc_e). + enu_los(az, el) = [cos(el) * sin(az), cos(el) * cos(az), sin(el)] + function ecef_los(user, az, el) + e = enu_los(az, el) + Vector(ECEFfromENU(user, wgs84)(ENU(e[1], e[2], e[3]))) .- Vector(user) + end + + # Non-degenerate location (ECEF ≠ ENU axes, so an un-rotated ECEF DOP would + # give visibly wrong HDOP/VDOP). + user = ECEFfromLLA(wgs84)(LLA(50.1, 8.7, 120.0)) + azels = [ + (0.0, deg2rad(80)), + (deg2rad(90), deg2rad(30)), + (deg2rad(180), deg2rad(45)), + (deg2rad(270), deg2rad(20)), + (deg2rad(45), deg2rad(60)), + ] + + # Single-system geometry: 3 position columns + 1 clock column. + H_ecef = reduce(vcat, [[ecef_los(user, az, el)' 1.0] for (az, el) in azels]) + dop = PositionVelocityTime.calc_DOP(H_ecef, user) + + # Reference DOP computed directly in the local ENU frame (RTKLIB-style: rows + # are the ENU line-of-sight components). calc_DOP must reproduce these from + # the ECEF H — this is what pins the ECEF→ENU rotation (issue: HDOP/VDOP were + # previously taken in ECEF and would fail here). + H_enu = reduce(vcat, [[enu_los(az, el)' 1.0] for (az, el) in azels]) + Q = inv(H_enu' * H_enu) + @test dop.HDOP ≈ sqrt(Q[1, 1] + Q[2, 2]) + @test dop.VDOP ≈ sqrt(Q[3, 3]) + @test dop.PDOP ≈ sqrt(Q[1, 1] + Q[2, 2] + Q[3, 3]) + @test dop.TDOP ≈ sqrt(Q[4, 4]) + @test dop.GDOP ≈ sqrt(tr(Q)) + + # Internal consistency / ordering. + @test dop.PDOP ≈ hypot(dop.HDOP, dop.VDOP) + @test dop.HDOP ≤ dop.PDOP ≤ dop.GDOP + @test dop.VDOP ≤ dop.PDOP + @test all(>(0), (dop.GDOP, dop.PDOP, dop.HDOP, dop.VDOP, dop.TDOP)) + + # Rank-deficient geometry (identical rows) → singular HᵀH → sentinel + # DOP(-1, …) returned gracefully, not an exception. + H_singular = repeat([1.0 0.0 0.0 1.0], 4) + dop_singular = PositionVelocityTime.calc_DOP(H_singular, user) + @test dop_singular.GDOP == -1 + @test dop_singular.PDOP == -1 + + # Multi-GNSS: 3 position + 2 clock columns (3 sats per system). GDOP/HDOP are + # independent of which clock is "primary"; TDOP reports the selected system's + # clock variance, so it changes with primary_clock_index. + azels6 = [azels; (deg2rad(300), deg2rad(35))] + sys = [1, 1, 1, 2, 2, 2] + H_multi = reduce( + vcat, + [ + [ecef_los(user, az, el)' (sys[i]==1 ? 1.0 : 0.0) (sys[i]==2 ? 1.0 : 0.0)] for + (i, (az, el)) in enumerate(azels6) + ], + ) + d1 = PositionVelocityTime.calc_DOP(H_multi, user, 1) + d2 = PositionVelocityTime.calc_DOP(H_multi, user, 2) + @test d1.GDOP ≈ d2.GDOP + @test d1.HDOP ≈ d2.HDOP + @test d1.PDOP ≈ d2.PDOP + @test !(d1.TDOP ≈ d2.TDOP) + @test d1.GDOP > 0 +end diff --git a/test/fixtures.jl b/test/fixtures.jl new file mode 100644 index 0000000..4558a35 --- /dev/null +++ b/test/fixtures.jl @@ -0,0 +1,1392 @@ +# Real-world satellite-state fixtures shared by the test suite (test/pvt.jl and +# friends) and the benchmarks (benchmark/fixtures.jl): decoder data captured over +# Aachen, 2021-05-31, with observables that converge to a real position. Kept in one +# place so the benchmark inputs cannot drift from the test fixtures. +# +# Needs only GNSSDecoder, GNSSSignals, PositionVelocityTime and Unitful's Hz — no +# test-only dependencies, so the benchmark script can include it directly. + +# AirspeedVelocity runs the benchmark script against both the PR and the base +# revision, which may resolve different major versions of GNSSDecoder. v3 renamed +# GPSL1CAData's health field `svhealth` -> `sv_health`; feature-detect which one the +# resolved version has and route the keyword accordingly. Drop this shim once the +# base revision of the benchmark comparison resolves GNSSDecoder 3. +gps_l1ca_data(; sv_health, kwargs...) = + :sv_health in fieldnames(GNSSDecoder.GPSL1CAData) ? + GNSSDecoder.GPSL1CAData(; sv_health, kwargs...) : + GNSSDecoder.GPSL1CAData(; svhealth = sv_health, kwargs...) + +"5 Galileo E1B satellites; `freq_offset` shifts every carrier Doppler." +function galileo_e1b_states(freq_offset) + galileo_e1b = GalileoE1B() + [ + SatelliteState(; + decoder = GNSSDecoderState( + 2, + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = 1.885093958164237, + e = 0.0002336719771847129, + sqrt_A = 5440.601516723633, + Ω_0 = 0.43151849653459906, + i_0 = 0.9782668574498574, + ω = 0.18081747042485646, + i_dot = -3.6965825489294766e-10, + Ω_dot = -5.353794435599309e-9, + Δn = 3.053341469642328e-9, + C_uc = 1.9334256649017334e-6, + C_us = 8.616596460342407e-6, + C_rc = 163.65625, + C_rs = 40.46875, + C_ic = -4.284083843231201e-8, + C_is = 7.264316082000732e-8, + t_0c = 132000.0, + a_f0 = 0.00022286397870630026, + a_f1 = 2.5721647034515627e-12, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -1.6298145055770874e-9, + broadcast_group_delay_e1_e5b = -2.0954757928848267e-9, + ), + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = 1.885093958164237, + e = 0.0002336719771847129, + sqrt_A = 5440.601516723633, + Ω_0 = 0.43151849653459906, + i_0 = 0.9782668574498574, + ω = 0.18081747042485646, + i_dot = -3.6965825489294766e-10, + Ω_dot = -5.353794435599309e-9, + Δn = 3.053341469642328e-9, + C_uc = 1.9334256649017334e-6, + C_us = 8.616596460342407e-6, + C_rc = 163.65625, + C_rs = 40.46875, + C_ic = -4.284083843231201e-8, + C_is = 7.264316082000732e-8, + t_0c = 132000.0, + a_f0 = 0.00022286397870630026, + a_f1 = 2.5721647034515627e-12, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -1.6298145055770874e-9, + broadcast_group_delay_e1_e5b = -2.0954757928848267e-9, + ), + GNSSDecoder.GalileoE1BConstants( + 250, + 0x0160, + 10, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986004418e14, + -4.442807309e-10, + ), + GNSSDecoder.GalileoE1BCache(), + 1549, + false, + ), + system = galileo_e1b, + carrier_doppler = 1617.3312825078192Hz + freq_offset, + code_phase = 2216.5793761534956, + carrier_phase = -1.461823180908076, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 4, + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 128400.0, + M_0 = 1.1883716376830096, + e = 0.0003440612927079201, + sqrt_A = 5440.625782012939, + Ω_0 = -1.666444269041331, + i_0 = 0.9544851798532423, + ω = -1.401875745066068, + i_dot = 4.318037006430664e-10, + Ω_dot = -5.442726711414135e-9, + Δn = 3.016554223020131e-9, + C_uc = -2.2649765014648438e-6, + C_us = 9.763985872268677e-6, + C_rc = 128.46875, + C_rs = -51.40625, + C_ic = 5.029141902923584e-8, + C_is = -2.2351741790771484e-8, + t_0c = 128400.0, + a_f0 = -0.0007824758649803698, + a_f1 = -8.01492205937393e-12, + a_f2 = 0.0, + IOD_nav1 = 0x0000000000000056, + IOD_nav2 = 0x0000000000000056, + IOD_nav3 = 0x0000000000000056, + IOD_nav4 = 0x0000000000000056, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -3.026798367500305e-9, + broadcast_group_delay_e1_e5b = -3.4924596548080444e-9, + ), + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 128400.0, + M_0 = 1.1883716376830096, + e = 0.0003440612927079201, + sqrt_A = 5440.625782012939, + Ω_0 = -1.666444269041331, + i_0 = 0.9544851798532423, + ω = -1.401875745066068, + i_dot = 4.318037006430664e-10, + Ω_dot = -5.442726711414135e-9, + Δn = 3.016554223020131e-9, + C_uc = -2.2649765014648438e-6, + C_us = 9.763985872268677e-6, + C_rc = 128.46875, + C_rs = -51.40625, + C_ic = 5.029141902923584e-8, + C_is = -2.2351741790771484e-8, + t_0c = 128400.0, + a_f0 = -0.0007824758649803698, + a_f1 = -8.01492205937393e-12, + a_f2 = 0.0, + IOD_nav1 = 0x0000000000000056, + IOD_nav2 = 0x0000000000000056, + IOD_nav3 = 0x0000000000000056, + IOD_nav4 = 0x0000000000000056, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -3.026798367500305e-9, + broadcast_group_delay_e1_e5b = -3.4924596548080444e-9, + ), + GNSSDecoder.GalileoE1BConstants( + 250, + 0x0160, + 10, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986004418e14, + -4.442807309e-10, + ), + GNSSDecoder.GalileoE1BCache(), + 1546, + true, + ), + system = galileo_e1b, + carrier_doppler = 4055.9318556579988Hz + freq_offset, + code_phase = 48.75149191469789, + carrier_phase = -2.3877702498883373, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 11, + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = 0.7593379982069668, + e = 0.0005043556448072195, + sqrt_A = 5440.614900588989, + Ω_0 = 2.515118401165434, + i_0 = 0.9919634586882758, + ω = 0.7810709950849496, + i_dot = -1.821504444400032e-11, + Ω_dot = -5.659164298336962e-9, + Δn = 2.699398155054008e-9, + C_uc = 6.221234798431396e-7, + C_us = 2.125278115272522e-6, + C_rc = 311.3125, + C_rs = 12.25, + C_ic = -2.9802322387695312e-8, + C_is = 5.4016709327697754e-8, + t_0c = 132000.0, + a_f0 = 0.005911215674132109, + a_f1 = -4.936850928061176e-11, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -1.3271346688270569e-8, + broadcast_group_delay_e1_e5b = -1.4435499906539917e-8, + ), + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = 0.7593379982069668, + e = 0.0005043556448072195, + sqrt_A = 5440.614900588989, + Ω_0 = 2.515118401165434, + i_0 = 0.9919634586882758, + ω = 0.7810709950849496, + i_dot = -1.821504444400032e-11, + Ω_dot = -5.659164298336962e-9, + Δn = 2.699398155054008e-9, + C_uc = 6.221234798431396e-7, + C_us = 2.125278115272522e-6, + C_rc = 311.3125, + C_rs = 12.25, + C_ic = -2.9802322387695312e-8, + C_is = 5.4016709327697754e-8, + t_0c = 132000.0, + a_f0 = 0.005911215674132109, + a_f1 = -4.936850928061176e-11, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -1.3271346688270569e-8, + broadcast_group_delay_e1_e5b = -1.4435499906539917e-8, + ), + GNSSDecoder.GalileoE1BConstants( + 250, + 0x0160, + 10, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986004418e14, + -4.442807309e-10, + ), + GNSSDecoder.GalileoE1BCache(), + 1551, + false, + ), + system = galileo_e1b, + carrier_doppler = 209.7024609093128Hz + freq_offset, + code_phase = 69.54389556899758, + carrier_phase = -1.4138935647217596, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 25, + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = -2.0355462626837646, + e = 0.00014415336772799492, + sqrt_A = 5440.611436843872, + Ω_0 = 0.42869178039052547, + i_0 = 0.9778386598651378, + ω = -1.4136735049701232, + i_dot = -3.982308736286344e-10, + Ω_dot = -5.379866950195624e-9, + Δn = 3.036555056135112e-9, + C_uc = 1.9241124391555786e-6, + C_us = 8.305534720420837e-6, + C_rc = 172.59375, + C_rs = 40.625, + C_ic = -7.450580596923828e-8, + C_is = 3.3527612686157227e-8, + t_0c = 132000.0, + a_f0 = -0.000535494415089488, + a_f1 = -1.2221335055073723e-12, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = 3.4924596548080444e-9, + broadcast_group_delay_e1_e5b = 3.958120942115784e-9, + ), + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = -2.0355462626837646, + e = 0.00014415336772799492, + sqrt_A = 5440.611436843872, + Ω_0 = 0.42869178039052547, + i_0 = 0.9778386598651378, + ω = -1.4136735049701232, + i_dot = -3.982308736286344e-10, + Ω_dot = -5.379866950195624e-9, + Δn = 3.036555056135112e-9, + C_uc = 1.9241124391555786e-6, + C_us = 8.305534720420837e-6, + C_rc = 172.59375, + C_rs = 40.625, + C_ic = -7.450580596923828e-8, + C_is = 3.3527612686157227e-8, + t_0c = 132000.0, + a_f0 = -0.000535494415089488, + a_f1 = -1.2221335055073723e-12, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = 3.4924596548080444e-9, + broadcast_group_delay_e1_e5b = 3.958120942115784e-9, + ), + GNSSDecoder.GalileoE1BConstants( + 250, + 0x0160, + 10, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986004418e14, + -4.442807309e-10, + ), + GNSSDecoder.GalileoE1BCache(), + 1548, + true, + ), + system = galileo_e1b, + carrier_doppler = -974.6289079820336Hz + freq_offset, + code_phase = 1701.8894620076721, + carrier_phase = -1.721713905186919, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 30, + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = 0.43031807277213097, + e = 0.00031052413396537304, + sqrt_A = 5440.595668792725, + Ω_0 = 0.43339143836728705, + i_0 = 0.9757531121479899, + ω = 0.869054289656611, + i_dot = -3.9465929628667357e-10, + Ω_dot = -5.42129724736237e-9, + Δn = 3.102986394695584e-9, + C_uc = 2.089887857437134e-6, + C_us = 8.553266525268555e-6, + C_rc = 162.5, + C_rs = 45.375, + C_ic = 8.754432201385498e-8, + C_is = 8.195638656616211e-8, + t_0c = 132000.0, + a_f0 = 0.002901351428590715, + a_f1 = -3.0667024475405924e-11, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = 1.6298145055770874e-9, + broadcast_group_delay_e1_e5b = 1.6298145055770874e-9, + ), + GNSSDecoder.GalileoE1BData( + WN = 1136, + TOW = 132769, + t_0e = 132000.0, + M_0 = 0.43031807277213097, + e = 0.00031052413396537304, + sqrt_A = 5440.595668792725, + Ω_0 = 0.43339143836728705, + i_0 = 0.9757531121479899, + ω = 0.869054289656611, + i_dot = -3.9465929628667357e-10, + Ω_dot = -5.42129724736237e-9, + Δn = 3.102986394695584e-9, + C_uc = 2.089887857437134e-6, + C_us = 8.553266525268555e-6, + C_rc = 162.5, + C_rs = 45.375, + C_ic = 8.754432201385498e-8, + C_is = 8.195638656616211e-8, + t_0c = 132000.0, + a_f0 = 0.002901351428590715, + a_f1 = -3.0667024475405924e-11, + a_f2 = 0.0, + IOD_nav1 = 0x000000000000005c, + IOD_nav2 = 0x000000000000005c, + IOD_nav3 = 0x000000000000005c, + IOD_nav4 = 0x000000000000005c, + num_pages_after_last_TOW = 5, + num_bits_after_valid_syncro_sequence_after_last_TOW = 0, + signal_health_e1b = GNSSDecoder.signal_ok, + signal_health_e5b = GNSSDecoder.signal_ok, + data_validity_status_e1b = GNSSDecoder.navigation_data_valid, + data_validity_status_e5b = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = 1.6298145055770874e-9, + broadcast_group_delay_e1_e5b = 1.6298145055770874e-9, + ), + GNSSDecoder.GalileoE1BConstants( + 250, + 0x0160, + 10, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986004418e14, + -4.442807309e-10, + ), + GNSSDecoder.GalileoE1BCache(), + 1547, + true, + ), + system = galileo_e1b, + carrier_doppler = 4089.415808665647Hz + freq_offset, + code_phase = 4015.495436832823, + carrier_phase = -2.8554107708683047, + ), + ] +end + +"9 GPS L1 C/A satellites; `freq_offset` shifts every carrier Doppler." +function gps_l1_states(freq_offset) + gpsl1 = GPSL1CA() + [ + SatelliteState(; + decoder = GNSSDecoderState( + 7, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001000110", + l2pcode = false, + T_GD = -1.1175870895385742e-8, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = 1.0231815394945443e-11, + a_f0 = 0.0001659449189901352, + IODE_Sub_2 = "01000110", + C_rs = 14.90625, + Δn = 4.9980653323400095e-9, + M_0 = -1.5018856847786306, + C_uc = 8.065253496170044e-7, + e = 0.014979799510911107, + C_us = 6.051734089851379e-6, + sqrt_A = 5153.747692108154, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = 3.110617399215698e-7, + Ω_0 = -0.42148546787582664, + C_is = -5.21540641784668e-8, + i_0 = 0.9517858648559379, + C_rc = 250.375, + ω = -2.331033772387461, + Ω_dot = -8.050692486513945e-9, + IODE_Sub_3 = "01000110", + i_dot = 1.657211886669833e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001000110", + l2pcode = false, + T_GD = -1.1175870895385742e-8, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = 1.0231815394945443e-11, + a_f0 = 0.0001659449189901352, + IODE_Sub_2 = "01000110", + C_rs = 14.90625, + Δn = 4.9980653323400095e-9, + M_0 = -1.5018856847786306, + C_uc = 8.065253496170044e-7, + e = 0.014979799510911107, + C_us = 6.051734089851379e-6, + sqrt_A = 5153.747692108154, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = 3.110617399215698e-7, + Ω_0 = -0.42148546787582664, + C_is = -5.21540641784668e-8, + i_0 = 0.9517858648559379, + C_rc = 250.375, + ω = -2.331033772387461, + Ω_dot = -8.050692486513945e-9, + IODE_Sub_3 = "01000110", + i_dot = 1.657211886669833e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + false, + ), + system = gpsl1, + carrier_doppler = 2669.8440799388595Hz + freq_offset, + code_phase = 4876.431542382193, + carrier_phase = 0.09402551301430394, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 8, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001100111", + l2pcode = false, + T_GD = 5.122274160385132e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -1.4779288903810084e-12, + a_f0 = -2.3324042558670044e-5, + IODE_Sub_2 = "01100111", + C_rs = 84.21875, + Δn = 4.435899058715372e-9, + M_0 = 1.3315461632138303, + C_uc = 4.639849066734314e-6, + e = 0.006349898059852421, + C_us = 6.3907355070114136e-6, + sqrt_A = 5153.606374740601, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = 6.891787052154541e-8, + Ω_0 = 1.6470055929195884, + C_is = 7.450580596923828e-8, + i_0 = 0.9671090437449873, + C_rc = 257.125, + ω = 0.05450158504406715, + Ω_dot = -8.087122575401946e-9, + IODE_Sub_3 = "01100111", + i_dot = -1.353627812603161e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001100111", + l2pcode = false, + T_GD = 5.122274160385132e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -1.4779288903810084e-12, + a_f0 = -2.3324042558670044e-5, + IODE_Sub_2 = "01100111", + C_rs = 84.21875, + Δn = 4.435899058715372e-9, + M_0 = 1.3315461632138303, + C_uc = 4.639849066734314e-6, + e = 0.006349898059852421, + C_us = 6.3907355070114136e-6, + sqrt_A = 5153.606374740601, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = 6.891787052154541e-8, + Ω_0 = 1.6470055929195884, + C_is = 7.450580596923828e-8, + i_0 = 0.9671090437449873, + C_rc = 257.125, + ω = 0.05450158504406715, + Ω_dot = -8.087122575401946e-9, + IODE_Sub_3 = "01100111", + i_dot = -1.353627812603161e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + false, + ), + system = gpsl1, + carrier_doppler = 4704.3549972665805Hz + freq_offset, + code_phase = 8455.107739656896, + carrier_phase = 2.7614518715603946, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 10, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0000101000", + l2pcode = false, + T_GD = 2.3283064365386963e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -7.617018127348274e-12, + a_f0 = -0.0001329910010099411, + IODE_Sub_2 = "00101000", + C_rs = -89.25, + Δn = 4.016595878769169e-9, + M_0 = -2.941058160546095, + C_uc = -4.645437002182007e-6, + e = 0.006653358926996589, + C_us = 7.499009370803833e-6, + sqrt_A = 5153.6786251068115, + t_0e = 136800, + fit_interval = false, + AODO = 20, + C_ic = -3.5390257835388184e-8, + Ω_0 = -2.5268399079275254, + C_is = -6.146728992462158e-8, + i_0 = 0.969412731565689, + C_rc = 239.09375, + ω = -2.5641372296478258, + Ω_dot = -7.765323456891273e-9, + IODE_Sub_3 = "00101000", + i_dot = 3.893019302737323e-11, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0000101000", + l2pcode = false, + T_GD = 2.3283064365386963e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -7.617018127348274e-12, + a_f0 = -0.0001329910010099411, + IODE_Sub_2 = "00101000", + C_rs = -89.25, + Δn = 4.016595878769169e-9, + M_0 = -2.941058160546095, + C_uc = -4.645437002182007e-6, + e = 0.006653358926996589, + C_us = 7.499009370803833e-6, + sqrt_A = 5153.6786251068115, + t_0e = 136800, + fit_interval = false, + AODO = 20, + C_ic = -3.5390257835388184e-8, + Ω_0 = -2.5268399079275254, + C_is = -6.146728992462158e-8, + i_0 = 0.969412731565689, + C_rc = 239.09375, + ω = -2.5641372296478258, + Ω_dot = -7.765323456891273e-9, + IODE_Sub_3 = "00101000", + i_dot = 3.893019302737323e-11, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + true, + ), + system = gpsl1, + carrier_doppler = 4603.179391134832Hz + freq_offset, + code_phase = 10510.15670303955, + carrier_phase = -0.7447786034769108, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 15, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001011001", + l2pcode = false, + T_GD = -1.0710209608078003e-8, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = 2.6147972675971687e-12, + a_f0 = -0.00014561135321855545, + IODE_Sub_2 = "01011001", + C_rs = -30.5625, + Δn = 5.182001565450993e-9, + M_0 = 0.38753804422936994, + C_uc = -1.471489667892456e-6, + e = 0.013163934461772442, + C_us = 9.061768651008606e-6, + sqrt_A = 5153.620079040527, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -1.695007085800171e-7, + Ω_0 = -1.5933596086804434, + C_is = 1.6205012798309326e-7, + i_0 = 0.9286304727294836, + C_rc = 190.6875, + ω = 0.98557755063243, + Ω_dot = -8.41785063726752e-9, + IODE_Sub_3 = "01011001", + i_dot = 3.010839699272994e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001011001", + l2pcode = false, + T_GD = -1.0710209608078003e-8, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = 2.6147972675971687e-12, + a_f0 = -0.00014561135321855545, + IODE_Sub_2 = "01011001", + C_rs = -30.5625, + Δn = 5.182001565450993e-9, + M_0 = 0.38753804422936994, + C_uc = -1.471489667892456e-6, + e = 0.013163934461772442, + C_us = 9.061768651008606e-6, + sqrt_A = 5153.620079040527, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -1.695007085800171e-7, + Ω_0 = -1.5933596086804434, + C_is = 1.6205012798309326e-7, + i_0 = 0.9286304727294836, + C_rc = 190.6875, + ω = 0.98557755063243, + Ω_dot = -8.41785063726752e-9, + IODE_Sub_3 = "01011001", + i_dot = 3.010839699272994e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + false, + ), + system = gpsl1, + carrier_doppler = 3144.174219887768Hz + freq_offset, + code_phase = 3618.5099300503684, + carrier_phase = -0.22375187424152987, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 16, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0000100010", + l2pcode = false, + T_GD = -1.0244548320770264e-8, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -6.0254023992456496e-12, + a_f0 = -0.0003338884562253952, + IODE_Sub_2 = "00100010", + C_rs = 37.0, + Δn = 4.26732060817482e-9, + M_0 = 2.20050848506247, + C_uc = 1.8272548913955688e-6, + e = 0.012199128163047135, + C_us = 9.119510650634766e-6, + sqrt_A = 5153.70516204834, + t_0e = 136800, + fit_interval = false, + AODO = 22, + C_ic = -2.3655593395233154e-7, + Ω_0 = 0.7074087615001045, + C_is = -1.30385160446167e-7, + i_0 = 0.9738089978878275, + C_rc = 212.75, + ω = 0.6779943621465904, + Ω_dot = -7.692463279115272e-9, + IODE_Sub_3 = "00100010", + i_dot = -2.95012288445966e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0000100010", + l2pcode = false, + T_GD = -1.0244548320770264e-8, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -6.0254023992456496e-12, + a_f0 = -0.0003338884562253952, + IODE_Sub_2 = "00100010", + C_rs = 37.0, + Δn = 4.26732060817482e-9, + M_0 = 2.20050848506247, + C_uc = 1.8272548913955688e-6, + e = 0.012199128163047135, + C_us = 9.119510650634766e-6, + sqrt_A = 5153.70516204834, + t_0e = 136800, + fit_interval = false, + AODO = 22, + C_ic = -2.3655593395233154e-7, + Ω_0 = 0.7074087615001045, + C_is = -1.30385160446167e-7, + i_0 = 0.9738089978878275, + C_rc = 212.75, + ω = 0.6779943621465904, + Ω_dot = -7.692463279115272e-9, + IODE_Sub_3 = "00100010", + i_dot = -2.95012288445966e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + false, + ), + system = gpsl1, + carrier_doppler = 595.7926881306387Hz + freq_offset, + code_phase = 17600.66651489137, + carrier_phase = 2.59152430602131, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 18, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "1111100001", + l2pcode = false, + T_GD = -8.381903171539307e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -1.3642420526593924e-12, + a_f0 = 0.0003486964851617813, + IODE_Sub_2 = "11100001", + C_rs = -15.3125, + Δn = 4.35303846438188e-9, + M_0 = -0.6041391930737793, + C_uc = -8.717179298400879e-7, + e = 0.0014569575432687998, + C_us = 3.339722752571106e-6, + sqrt_A = 5153.662895202637, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = 2.60770320892334e-8, + Ω_0 = 2.733796625968394, + C_is = -4.284083843231201e-8, + i_0 = 0.9683542210973364, + C_rc = 321.59375, + ω = 2.894680446328142, + Ω_dot = -8.184269479103281e-9, + IODE_Sub_3 = "11100001", + i_dot = -1.0071848104329588e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "1111100001", + l2pcode = false, + T_GD = -8.381903171539307e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -1.3642420526593924e-12, + a_f0 = 0.0003486964851617813, + IODE_Sub_2 = "11100001", + C_rs = -15.3125, + Δn = 4.35303846438188e-9, + M_0 = -0.6041391930737793, + C_uc = -8.717179298400879e-7, + e = 0.0014569575432687998, + C_us = 3.339722752571106e-6, + sqrt_A = 5153.662895202637, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = 2.60770320892334e-8, + Ω_0 = 2.733796625968394, + C_is = -4.284083843231201e-8, + i_0 = 0.9683542210973364, + C_rc = 321.59375, + ω = 2.894680446328142, + Ω_dot = -8.184269479103281e-9, + IODE_Sub_3 = "11100001", + i_dot = -1.0071848104329588e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + true, + ), + system = gpsl1, + carrier_doppler = -794.0022484221436Hz + freq_offset, + code_phase = 14504.587373634655, + carrier_phase = -0.7416765461612318, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 23, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0011101110", + l2pcode = false, + T_GD = -8.381903171539307e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -3.751665644813329e-12, + a_f0 = 0.00010169018059968948, + IODE_Sub_2 = "11101110", + C_rs = -92.40625, + Δn = 4.153030133232073e-9, + M_0 = -1.3144457890174628, + C_uc = -4.732981324195862e-6, + e = 0.0012323472183197737, + C_us = 7.256865501403809e-6, + sqrt_A = 5153.657176971436, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -9.872019290924072e-8, + Ω_0 = -2.5503370450318865, + C_is = 2.9802322387695312e-8, + i_0 = 0.9644177455387573, + C_rc = 242.5, + ω = 2.603874239368067, + Ω_dot = -7.890328663859904e-9, + IODE_Sub_3 = "11101110", + i_dot = 3.107272287505937e-11, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0011101110", + l2pcode = false, + T_GD = -8.381903171539307e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -3.751665644813329e-12, + a_f0 = 0.00010169018059968948, + IODE_Sub_2 = "11101110", + C_rs = -92.40625, + Δn = 4.153030133232073e-9, + M_0 = -1.3144457890174628, + C_uc = -4.732981324195862e-6, + e = 0.0012323472183197737, + C_us = 7.256865501403809e-6, + sqrt_A = 5153.657176971436, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -9.872019290924072e-8, + Ω_0 = -2.5503370450318865, + C_is = 2.9802322387695312e-8, + i_0 = 0.9644177455387573, + C_rc = 242.5, + ω = 2.603874239368067, + Ω_dot = -7.890328663859904e-9, + IODE_Sub_3 = "11101110", + i_dot = 3.107272287505937e-11, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + true, + ), + system = gpsl1, + carrier_doppler = 2826.783251528247Hz + freq_offset, + code_phase = 14394.465193705755, + carrier_phase = -0.7597910878699417, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 26, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001101110", + l2pcode = false, + T_GD = 6.984919309616089e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = 4.888534022029489e-12, + a_f0 = 9.242305532097816e-5, + IODE_Sub_2 = "01101110", + C_rs = 29.53125, + Δn = 5.002351225150362e-9, + M_0 = 3.0615288470223163, + C_uc = 1.5329569578170776e-6, + e = 0.00589060562197119, + C_us = 8.42846930027008e-6, + sqrt_A = 5153.568691253662, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -1.043081283569336e-7, + Ω_0 = 0.5730118600154152, + C_is = 1.30385160446167e-8, + i_0 = 0.942073253292395, + C_rc = 208.875, + ω = 0.3072108597425673, + Ω_dot = -8.057478483463671e-9, + IODE_Sub_3 = "01101110", + i_dot = -3.328710082707509e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001101110", + l2pcode = false, + T_GD = 6.984919309616089e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = 4.888534022029489e-12, + a_f0 = 9.242305532097816e-5, + IODE_Sub_2 = "01101110", + C_rs = 29.53125, + Δn = 5.002351225150362e-9, + M_0 = 3.0615288470223163, + C_uc = 1.5329569578170776e-6, + e = 0.00589060562197119, + C_us = 8.42846930027008e-6, + sqrt_A = 5153.568691253662, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -1.043081283569336e-7, + Ω_0 = 0.5730118600154152, + C_is = 1.30385160446167e-8, + i_0 = 0.942073253292395, + C_rc = 208.875, + ω = 0.3072108597425673, + Ω_dot = -8.057478483463671e-9, + IODE_Sub_3 = "01101110", + i_dot = -3.328710082707509e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + true, + ), + system = gpsl1, + carrier_doppler = -1083.2317687377372Hz + freq_offset, + code_phase = 13905.233170289455, + carrier_phase = -2.1444956909602526, + ), + SatelliteState(; + decoder = GNSSDecoderState( + 27, + gps_l1ca_data( + last_subframe_id = 4, + integrity_status_flag = false, + TOW = nothing, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001001110", + l2pcode = false, + T_GD = 1.862645149230957e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -6.366462912410498e-12, + a_f0 = -0.0001393994316458702, + IODE_Sub_2 = "01001110", + C_rs = 82.25, + Δn = 4.257677349351526e-9, + M_0 = 1.25128819523303, + C_uc = 4.505738615989685e-6, + e = 0.00944566575344652, + C_us = 6.81169331073761e-6, + sqrt_A = 5153.686637878418, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -5.029141902923584e-8, + Ω_0 = 1.66429757866306, + C_is = 1.601874828338623e-7, + i_0 = 0.9756900633046096, + C_rc = 257.375, + ω = 0.6281523291801417, + Ω_dot = -8.07676500111026e-9, + IODE_Sub_3 = "01001110", + i_dot = -1.2286226056345314e-10, + ), + gps_l1ca_data( + last_subframe_id = 3, + integrity_status_flag = false, + TOW = 132768, + alert_flag = false, + anti_spoof_flag = true, + trans_week = 112, + codeonl2 = 1, + ura = 2.0, + sv_health = "000000", + IODC = "0001001110", + l2pcode = false, + T_GD = 1.862645149230957e-9, + t_0c = 136800, + a_f2 = 0.0, + a_f1 = -6.366462912410498e-12, + a_f0 = -0.0001393994316458702, + IODE_Sub_2 = "01001110", + C_rs = 82.25, + Δn = 4.257677349351526e-9, + M_0 = 1.25128819523303, + C_uc = 4.505738615989685e-6, + e = 0.00944566575344652, + C_us = 6.81169331073761e-6, + sqrt_A = 5153.686637878418, + t_0e = 136800, + fit_interval = false, + AODO = 31, + C_ic = -5.029141902923584e-8, + Ω_0 = 1.66429757866306, + C_is = 1.601874828338623e-7, + i_0 = 0.9756900633046096, + C_rc = 257.375, + ω = 0.6281523291801417, + Ω_dot = -8.07676500111026e-9, + IODE_Sub_3 = "01001110", + i_dot = -1.2286226056345314e-10, + ), + GNSSDecoder.GPSL1CAConstants( + 300, + 0x8b, + 8, + 30, + 3.1415926535898, + 7.2921151467e-5, + 2.99792458e8, + 3.986005e14, + -4.442807633e-10, + ), + GNSSDecoder.GPSL1CACache(), + 360, + false, + ), + system = gpsl1, + carrier_doppler = 3393.7445379085816Hz + freq_offset, + code_phase = 16861.02713837273, + carrier_phase = -0.9185401811870872, + ), + ] +end diff --git a/test/galileo_e5a.jl b/test/galileo_e5a.jl new file mode 100644 index 0000000..1972685 --- /dev/null +++ b/test/galileo_e5a.jl @@ -0,0 +1,71 @@ +# Galileo E5a F/NAV (GNSSDecoder.GalileoE5aData) support. +# +# E5a is a separate frequency band from E1, so a satellite tracked on both yields +# two independent measurements. E5a F/NAV shares the Galileo Keplerian ephemeris, +# clock, NTCM-G ionosphere and GGTO layout with the E1B I/NAV message — only its +# broadcast group delay (E1–E5a) and carrier band (1176.45 MHz) differ. +@testset "Galileo E5a (F/NAV) support" begin + + # Build an E5a decoder carrying the same Keplerian ephemeris as an E1B fixture + # satellite; the Galileo constants (μ, F, Ω̇ₑ) are identical, so the shared orbit + # path must reproduce the E1B result bit-for-bit. + base_e1b = galileo_e1b_states(0.0Hz)[1].decoder + d = base_e1b.data + e5a_state = GNSSDecoder.GalileoE5aDecoderState(2) + e5a_eph = GNSSDecoder.GalileoE5aData(; + WN = d.WN, TOW = d.TOW, t_0e = d.t_0e, M_0 = d.M_0, e = d.e, sqrt_A = d.sqrt_A, + Ω_0 = d.Ω_0, i_0 = d.i_0, ω = d.ω, i_dot = d.i_dot, Ω_dot = d.Ω_dot, Δn = d.Δn, + C_uc = d.C_uc, C_us = d.C_us, C_rc = d.C_rc, C_rs = d.C_rs, C_ic = d.C_ic, + C_is = d.C_is, t_0c = d.t_0c, a_f0 = d.a_f0, a_f1 = d.a_f1, a_f2 = d.a_f2, + broadcast_group_delay_e1_e5a = -3.5e-9, + signal_health_e5a = GNSSDecoder.signal_ok, + data_validity_status_e5a = GNSSDecoder.navigation_data_valid, + ) + swap(data) = GNSSDecoder.GNSSDecoderState(e5a_state; data = data, raw_data = data) + e5a = swap(e5a_eph) + + @testset "classification / signal id / week / start time" begin + @test GNSSSignals.get_time_system(GNSSSignals.GalileoE5aI()) == GNSSSignals.GST() + @test GNSSSignals.get_signal_id(GalileoE5aI()) == :GalileoE5aI + @test PositionVelocityTime.get_week(e5a) == d.WN # full WN, no rollover + @test PositionVelocityTime.system_start_epoch(GNSSSignals.GalileoE5aI()) == + PositionVelocityTime.system_start_epoch(GNSSSignals.GalileoE1B()) + end + + @testset "orbit matches the equivalent E1B Keplerian" begin + for t in (132000.0, 132769.0, 135000.0) + pe = PositionVelocityTime.calc_satellite_position_and_velocity(base_e1b, t) + pa = PositionVelocityTime.calc_satellite_position_and_velocity(e5a, t) + @test pa.position ≈ pe.position atol = 1e-6 + @test pa.velocity ≈ pe.velocity atol = 1e-9 + end + end + + @testset "group delay uses the E1–E5a BGD (per band, signal-agnostic)" begin + # Galileo BGD is per band, so it depends on the decoder, not the ranging + # signal — pass the data and pilot components and expect the same result. + @test PositionVelocityTime.correct_by_group_delay(e5a, GalileoE5aI(), 100.0) ≈ + 100.0 - e5a.data.broadcast_group_delay_e1_e5a + @test PositionVelocityTime.correct_by_group_delay(e5a, GalileoE5aQ(), 100.0) ≈ + 100.0 - e5a.data.broadcast_group_delay_e1_e5a + # The E1B I/NAV path applies the E1–E5b BGD instead — a different field. + @test PositionVelocityTime.correct_by_group_delay(base_e1b, GalileoE1B(), 100.0) ≈ + 100.0 - base_e1b.data.broadcast_group_delay_e1_e5b + end + + @testset "NTCM-G coefficients (shared model with E1B)" begin + @test PositionVelocityTime.ntcm_g_params(e5a) === nothing # none broadcast above + with_iono = swap(GNSSDecoder.GalileoE5aData(e5a_eph; a_i0 = 45.0, a_i1 = 0.1, a_i2 = 0.01)) + p = PositionVelocityTime.ntcm_g_params(with_iono) + @test p isa PositionVelocityTime.NTCMGParams + @test (p.a_i0, p.a_i1, p.a_i2, p.week_number) == (45.0, 0.1, 0.01, d.WN) + end + + @testset "GGTO (shared word-type-10 layout with E1B)" begin + @test !PositionVelocityTime.ggto_available(e5a) + g = swap(GNSSDecoder.GalileoE5aData(e5a_eph; A_0G = 5e-9, A_1G = 1e-15, t_0G = 100, WN_0G = 1134)) + @test PositionVelocityTime.ggto_available(g) + @test PositionVelocityTime.calc_ggto_offset(g, 132000.0) ≈ + 5e-9 + 1e-15 * (132000.0 - 100 + 604800 * mod(d.WN - 1134, 64)) + end +end diff --git a/test/gps_l2c.jl b/test/gps_l2c.jl new file mode 100644 index 0000000..0a77303 --- /dev/null +++ b/test/gps_l2c.jl @@ -0,0 +1,86 @@ +# GPS L2C (CNAV on the L2 band) support. +# +# L2C shares the CNAV `GNSSDecoder.GPSCNAVData` container with L5-I; the two are +# distinguished only by the decoder's constants type (`GPSL2CMConstants` vs +# `GPSL5IConstants`). So the orbit/clock/week/ionosphere paths are the L5 ones, +# exercised here on an L2C state to confirm the classification, the L2 frequency +# band, the 50 Hz L2 CM symbol rate and the ISC_L2C group delay are wired up. +@testset "GPS L2C (CNAV on L2)" begin + A_REF = 26_559_710.0 + Ωdot_REF = -2.6e-9 * π + + l2c = GNSSDecoder.GPSL2CMDecoderState(1) + l5 = GNSSDecoder.GPSL5IDecoderState(1) + swap(state, d) = GNSSDecoder.GNSSDecoderState(state; data = d, raw_data = d) + state_of(d) = SatelliteState(; decoder = swap(l2c, d), system = GNSSSignals.GPSL2CM(), + code_phase = 0.0, carrier_doppler = 0.0Hz, carrier_phase = 0.0) + + @testset "classification: GPS time system, L2 band, GPSL2CM signal id" begin + @test GNSSSignals.get_time_system(GNSSSignals.GPSL2CM()) == GNSSSignals.GPST() + @test !PositionVelocityTime.ggto_available(l2c) + @test GNSSSignals.get_band_id(GNSSSignals.GPSL2CM()) == :L2 + @test GNSSSignals.get_signal_id(GNSSSignals.GPSL2CM()) == :GPSL2CM + end + + @testset "nav-message symbol rate is L2 CM (50 Hz), not L5-I (100 Hz)" begin + d = GNSSDecoder.GPSCNAVData() + @test GNSSSignals.get_data_frequency(swap(l2c, d)) == + GNSSSignals.get_data_frequency(GNSSSignals.GPSL2CM()) + @test GNSSSignals.get_data_frequency(swap(l5, d)) == + GNSSSignals.get_data_frequency(GNSSSignals.GPSL5I()) + end + + @testset "get_week uses full broadcast WN (no rollover)" begin + # approximate_year must be ignored for CNAV (full 13-bit WN). + @test PositionVelocityTime.get_week(swap(l2c, GNSSDecoder.GPSCNAVData(; WN = 2200)); + approximate_year = 2042) == 2200 + end + + @testset "Klobuchar params from CNAV α_0…β_3" begin + d = GNSSDecoder.GPSCNAVData(; α_0 = 1e-8, α_1 = 2e-8, α_2 = 3e-8, α_3 = 4e-8, + β_0 = 1e4, β_1 = 2e4, β_2 = 3e4, β_3 = 4e4) + kp = PositionVelocityTime.klobuchar_params(swap(l2c, d)) + @test kp isa PositionVelocityTime.KlobucharParams + @test kp.α_0 == 1e-8 + @test kp.β_3 == 4e4 + # Partially populated → nothing, not an error. + @test PositionVelocityTime.klobuchar_params(swap(l2c, GNSSDecoder.GPSCNAVData(; α_0 = 1e-8))) === + nothing + end + + # One CNAV decoder carries the whole ISC set (message type 30), so the group delay + # is selected by the ranging signal: ISC_L2C for an L2C-M range, ISC_L5I5 for an L5 + # range (IS-GPS-200N §30.3.3.3.1.1). -T_GD + ISC. + @testset "group delay selected by ranging signal (ISC_L2C vs ISC_L5I5)" begin + dc = swap(l2c, GNSSDecoder.GPSCNAVData(; T_GD = 5.0e-9, ISC_L2C = 7.0e-9, ISC_L5I5 = 1.0e-9)) + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL2CM(), 100.0) ≈ + 100.0 - 5.0e-9 + 7.0e-9 + @test PositionVelocityTime.correct_by_group_delay(dc, GNSSSignals.GPSL5I(), 100.0) ≈ + 100.0 - 5.0e-9 + 1.0e-9 + end + + # The orbit uses the shared CNAV quasi-Keplerian algorithm, so an L2C ephemeris with + # zero deltas must reproduce the directly-broadcast Keplerian (LNAV) orbit exactly. + @testset "CNAV orbit on L2C reduces to the equivalent Keplerian" begin + kep = (; M_0 = 0.3, e = 0.012, sqrt_A = 5153.65, Ω_0 = 1.0, i_0 = 0.96, ω = -0.5, + Δn = 4.7e-9, Ω_dot = -8.1e-9, i_dot = 1.3e-10, C_uc = 1e-6, C_us = 2e-6, + C_rc = 200.0, C_rs = 30.0, C_ic = -1e-7, C_is = 9e-8, t_0e = 300.0) + lnav = GNSSDecoder.GPSL1CAData(; M_0 = kep.M_0, e = kep.e, sqrt_A = kep.sqrt_A, + Ω_0 = kep.Ω_0, i_0 = kep.i_0, ω = kep.ω, Δn = kep.Δn, Ω_dot = kep.Ω_dot, + i_dot = kep.i_dot, C_uc = kep.C_uc, C_us = kep.C_us, C_rc = kep.C_rc, + C_rs = kep.C_rs, C_ic = kep.C_ic, C_is = kep.C_is, t_0e = kep.t_0e) + cnav = GNSSDecoder.GPSCNAVData(; M_0 = kep.M_0, e = kep.e, ΔA = kep.sqrt_A^2 - A_REF, + A_dot = 0.0, Δn_0 = kep.Δn, Δn_0_dot = 0.0, Ω_0 = kep.Ω_0, i_0 = kep.i_0, + ω = kep.ω, ΔΩ_dot = kep.Ω_dot - Ωdot_REF, i_dot = kep.i_dot, C_uc = kep.C_uc, + C_us = kep.C_us, C_rc = kep.C_rc, C_rs = kep.C_rs, C_ic = kep.C_ic, + C_is = kep.C_is, t_0e = kep.t_0e) + dl = swap(GNSSDecoder.GPSL1CADecoderState(1), lnav) + dc = swap(l2c, cnav) + for t in (300.0, 1500.0, 4000.0) + pl = PositionVelocityTime.calc_satellite_position_and_velocity(dl, t) + pc = PositionVelocityTime.calc_satellite_position_and_velocity(dc, t) + @test pc.position ≈ pl.position atol = 1e-6 + @test pc.velocity ≈ pl.velocity atol = 1e-9 + end + end +end diff --git a/test/inter_frequency_bias.jl b/test/inter_frequency_bias.jl new file mode 100644 index 0000000..d20b687 --- /dev/null +++ b/test/inter_frequency_bias.jl @@ -0,0 +1,229 @@ +# Receiver inter-frequency bias (IFB) estimation: when satellites are processed on +# more than one frequency band, calc_pvt estimates one extra unknown per band beyond +# the reference (shared across constellations on that band). +@testset "inter-frequency bias" begin + C = 299792458.0 + kw = (; approximate_year = 2021, enable_ionospheric_correction = false, + enable_tropospheric_correction = false) + + # Build an L5 (E5a) measurement for a Galileo E1B satellite, transmit-time-consistent + # with it (same ephemeris/clock, BGD matched, observables solved to the same epoch). + # `ifb_shift_s` injects a uniform receiver L5 delay (seconds); `ggto` (A_0G, seconds) + # makes the copy carry a broadcast Galileo–GPS Time Offset. + function as_e5a(state; ifb_shift_s = 0.0, ggto = nothing) + d = state.decoder.data + ggto_fields = isnothing(ggto) ? (;) : (; A_0G = ggto, A_1G = 0.0, t_0G = 0, WN_0G = d.WN) + e5a_data = GNSSDecoder.GalileoE5aData(; WN = d.WN, TOW = d.TOW, t_0e = d.t_0e, + M_0 = d.M_0, e = d.e, sqrt_A = d.sqrt_A, Ω_0 = d.Ω_0, i_0 = d.i_0, ω = d.ω, + i_dot = d.i_dot, Ω_dot = d.Ω_dot, Δn = d.Δn, C_uc = d.C_uc, C_us = d.C_us, + C_rc = d.C_rc, C_rs = d.C_rs, C_ic = d.C_ic, C_is = d.C_is, t_0c = d.t_0c, + a_f0 = d.a_f0, a_f1 = d.a_f1, a_f2 = d.a_f2, + broadcast_group_delay_e1_e5a = d.broadcast_group_delay_e1_e5b, + signal_health_e5a = GNSSDecoder.signal_ok, + data_validity_status_e5a = GNSSDecoder.navigation_data_valid, ggto_fields...) + dec = GNSSDecoder.GNSSDecoderState(GNSSDecoder.GalileoE5aDecoderState(state.decoder.prn); + data = e5a_data, raw_data = e5a_data, num_bits_after_valid_syncro_sequence = 0) + target = PositionVelocityTime.calc_uncorrected_time(state) + ifb_shift_s + codefreq = Float64(GNSSSignals.get_code_frequency(GalileoE5aI()) / Hz) + code_phase = (target - PositionVelocityTime.get_tow(dec)) * codefreq + SatelliteState(; decoder = dec, system = GalileoE5aI(), code_phase = code_phase, + carrier_doppler = 0.0Hz, carrier_phase = 0.0) + end + + # Build a GPS L2C (CNAV) copy of a GPS L1 C/A satellite: the same orbit and clock in + # the quasi-Keplerian CNAV form (ΔA from A_REF, ΔΩ̇ from Ω̇_REF, matching T_GD and + # ISC_L2C = 0), transmit-time-consistent with the L1 original. `ifb_shift_s` injects + # a uniform receiver L2 delay (seconds). + function as_l2c(state; ifb_shift_s = 0.0) + d = state.decoder.data + A_REF = 26_559_710.0 + Ωdot_REF = -2.6e-9 * π + l2c_data = GNSSDecoder.GPSCNAVData(; + WN = PositionVelocityTime.get_week(state.decoder; approximate_year = 2021), + TOW = d.TOW, t_0e = d.t_0e, M_0 = d.M_0, e = d.e, ΔA = d.sqrt_A^2 - A_REF, + A_dot = 0.0, Δn_0 = d.Δn, Δn_0_dot = 0.0, Ω_0 = d.Ω_0, i_0 = d.i_0, ω = d.ω, + i_dot = d.i_dot, ΔΩ_dot = d.Ω_dot - Ωdot_REF, C_uc = d.C_uc, C_us = d.C_us, + C_rc = d.C_rc, C_rs = d.C_rs, C_ic = d.C_ic, C_is = d.C_is, t_0c = d.t_0c, + a_f0 = d.a_f0, a_f1 = d.a_f1, a_f2 = d.a_f2, T_GD = d.T_GD, ISC_L2C = 0.0, + l2_health = false) + dec = GNSSDecoder.GNSSDecoderState(GNSSDecoder.GPSL2CMDecoderState(state.decoder.prn); + data = l2c_data, raw_data = l2c_data, num_bits_after_valid_syncro_sequence = 0) + target = PositionVelocityTime.calc_uncorrected_time(state) + ifb_shift_s + codefreq = Float64(GNSSSignals.get_code_frequency(GPSL2CM()) / Hz) + code_phase = (target - PositionVelocityTime.get_tow(dec)) * codefreq + SatelliteState(; decoder = dec, system = GPSL2CM(), code_phase = code_phase, + carrier_doppler = 0.0Hz, carrier_phase = 0.0) + end + + @testset "user_position recovers per-band IFB and per-system clocks" begin + user = ECEFfromLLA(wgs84)(LLA(50.1, 8.7, 120.0)) + ecef_from_enu = ECEFfromENU(user, wgs84) + # 8 satellites at ~GNSS altitude in varied geometry. + azels = [(0, 80), (60, 40), (120, 30), (180, 55), (240, 25), (300, 60), + (30, 70), (150, 20)] + sat(az, el) = Vector(ecef_from_enu(ENU( + cosd(el) * sind(az) * 2.02e7, cosd(el) * cosd(az) * 2.02e7, sind(el) * 2.02e7))) + sat_positions = reduce(hcat, [sat(az, el) for (az, el) in azels]) + + # Two time systems (1=GPS, 2=Galileo) and two bands (ref + one extra, e.g. L5). + # 1=GPS, 2=Galileo clock columns; ifb column 1 = extra band, 0 = reference. + bias_columns = PositionVelocityTime.BiasColumns([1, 1, 1, 1, 2, 2, 2, 1], 2, + [0, 0, 0, 1, 0, 0, 1, 1], 1) + + # Truth: position + tc_GPS = 30 m, tc_Gal = 80 m, ifb_extra = 17 m. + ξ_true = [user[1], user[2], user[3], 30.0, 80.0, 17.0] + ρ = zeros(8) + PositionVelocityTime.calc_ρ_hat!(ρ, sat_positions, ξ_true, bias_columns) + + ξ, resid = PositionVelocityTime.user_position(sat_positions, ρ, bias_columns) + @test ξ[1:3] ≈ [user[1], user[2], user[3]] atol = 1e-3 + @test ξ[4] ≈ 30.0 atol = 1e-4 # GPS clock + @test ξ[5] ≈ 80.0 atol = 1e-4 # Galileo clock + @test ξ[6] ≈ 17.0 atol = 1e-4 # inter-frequency bias of the extra band + @test maximum(abs, resid) < 1e-6 # consistent data → zero residual + + # Drop the IFB column and the same data no longer fits: the extra-band + # satellites carry an unmodeled 17 m offset. + bias_columns_no_ifb = + PositionVelocityTime.BiasColumns([1, 1, 1, 1, 2, 2, 2, 1], 2, zeros(Int, 8), 0) + _, resid_no_ifb = PositionVelocityTime.user_position(sat_positions, ρ, bias_columns_no_ifb) + @test maximum(abs, resid_no_ifb) > 1.0 + end + + @testset "band layout follows coverage-graph connectivity" begin + bcl = PositionVelocityTime.band_ifb_layout + # Disjoint: GPS only on L5, Galileo only on L1 ⇒ two components, no IFB column + # (each band is the sole band of its component, so its bias folds into a clock). + ifb, extra, ncomp = bcl([GPST(), GPST(), GST(), GST()], [:L5, :L5, :L1, :L1]) + @test ncomp == 2 + @test isempty(extra) + @test all(==(0), ifb) + # Connected via a constellation spanning both bands ⇒ one component, one IFB. + _, extra2, ncomp2 = bcl([GPST(), GPST(), GST(), GST()], [:L1, :L5, :L1, :L5]) + @test ncomp2 == 1 + @test length(extra2) == 1 + # Single constellation on two bands ⇒ connected by its shared clock ⇒ one IFB. + _, extra3, ncomp3 = bcl([GPST(), GPST()], [:L1, :L5]) + @test ncomp3 == 1 + @test length(extra3) == 1 + end + + @testset "layout count gate accounts for the extra band" begin + # GPS-only (no GGTO collapse possible), dummy states: 3 position + 1 clock + + # num_ifb unknowns. The decoder is only touched on the GGTO path, never reached + # for a GPS-only constellation, so plain integers stand in for the states. + decide = PositionVelocityTime.decide_bias_layout + # Dual-band GPS ⇒ 1 IFB ⇒ needs 5 satellites; 4 is too few. + @test decide(collect(1:5), fill(GPST(), 5), [:L1, :L5, :L1, :L5, :L1], zeros(5)) !== + nothing + @test decide(collect(1:4), fill(GPST(), 4), [:L1, :L5, :L1, :L5], zeros(4)) === nothing + # Single-band GPS ⇒ no IFB ⇒ 4 satellites suffice. + @test decide(collect(1:4), fill(GPST(), 4), fill(:L1, 4), zeros(4)) !== nothing + end + + @testset "single-band fix reports no inter-frequency bias" begin + # The L1-only GPS+Galileo fixtures share one band ⇒ no IFB unknown. + pvt = calc_pvt([gps_l1_states(0.0Hz); galileo_e1b_states(0.0Hz)]; kw...) + @test isempty(pvt.inter_frequency_biases) + @test length(pvt.sats) >= 4 + end + + # End-to-end through calc_pvt: a Galileo E1B (L1) + E5a (L5) two-band fix. The E5a + # copies reproduce each E1B satellite's transmit time (same ephemeris/clock, BGD + # matched, observables solved to the same epoch), so without an injected bias the + # IFB is zero and the fix is unchanged; a uniform L5 delay is then recovered as the + # inter-frequency bias rather than corrupting the position. + @testset "calc_pvt estimates the IFB across the L1 and L5 bands" begin + e1b = galileo_e1b_states(0.0Hz) + ref = calc_pvt(e1b; kw...) # L1-only Galileo fix + @test length(ref.sats) >= 4 + + # Consistent L5 copies ⇒ band grouping triggers, IFB ≈ 0, fix unchanged. The + # copies reproduce each E1B transmit time exactly, so the residuals are the + # L1-only fixtures' own ~m-level noise (the baseline), not zero. + base = maximum(abs, [info.residual for info in values(ref.sats)]) + pvt0 = calc_pvt([e1b; map(as_e5a, e1b)]; kw...) + @test pvt0.reference_system == GST() + @test haskey(pvt0.inter_frequency_biases, :L5) + @test length(pvt0.sats) == 2 * length(ref.sats) + @test norm(pvt0.position - ref.position) < 1e-2 + @test abs(pvt0.inter_frequency_biases[:L5]) < 1e-2 + @test maximum(abs, [info.residual for info in values(pvt0.sats)]) ≈ base atol = 1e-2 + + # A uniform 12 m receiver L5 delay (signals appear farther ⇒ earlier transmit + # time) is absorbed by the IFB, leaving the position and the residuals at the + # baseline — without the IFB unknown the L5 satellites would carry ~12 m + # residuals instead. + δ = 12.0 + pvtδ = calc_pvt([e1b; map(s -> as_e5a(s; ifb_shift_s = -δ / C), e1b)]; kw...) + @test pvtδ.inter_frequency_biases[:L5] ≈ δ atol = 0.05 + @test norm(pvtδ.position - ref.position) < 1e-2 + @test maximum(abs, [info.residual for info in values(pvtδ.sats)]) ≈ base atol = 0.05 + end + + # GPS two-band fix: L1 C/A + L2C. The L2C copies reproduce each L1 satellite's + # transmit time exactly, so without an injected bias the L2 inter-frequency bias is + # zero and the fix is unchanged; a uniform L2 delay is then recovered as the IFB + # rather than corrupting the position. Exercises the new L2 band through calc_pvt. + @testset "calc_pvt estimates the IFB across the GPS L1 and L2 bands" begin + gps = gps_l1_states(0.0Hz) + ref = calc_pvt(gps; kw...) # L1-only GPS fix + @test length(ref.sats) >= 4 + base = maximum(abs, [info.residual for info in values(ref.sats)]) + + # Consistent L2C copies ⇒ band grouping triggers, IFB ≈ 0, fix unchanged. + pvt0 = calc_pvt([gps; map(as_l2c, gps)]; kw...) + @test pvt0.reference_system == GPST() + @test haskey(pvt0.inter_frequency_biases, :L2) + @test length(pvt0.sats) == 2 * length(ref.sats) + @test norm(pvt0.position - ref.position) < 1e-2 + @test abs(pvt0.inter_frequency_biases[:L2]) < 1e-2 + @test maximum(abs, [info.residual for info in values(pvt0.sats)]) ≈ base atol = 1e-2 + + # A uniform 12 m receiver L2 delay is absorbed by the IFB, leaving the position + # and residuals at the baseline. + δ = 12.0 + pvtδ = calc_pvt([gps; map(s -> as_l2c(s; ifb_shift_s = -δ / C), gps)]; kw...) + @test pvtδ.inter_frequency_biases[:L2] ≈ δ atol = 0.05 + @test norm(pvtδ.position - ref.position) < 1e-2 + @test maximum(abs, [info.residual for info in values(pvtδ.sats)]) ≈ base atol = 0.05 + end + + # Regression for the disjoint-band bug: GPS on L1 only + Galileo on L5 only makes a + # band's IFB column collinear with the stranded constellation's clock. The + # component-aware layout must not return a degenerate (huge, canceling) solution. + @testset "disjoint-band coverage is observable, not degenerate" begin + gps_l1 = gps_l1_states(0.0Hz) # GPS on L1 only + # Connected all-L1 reference fix, for the true position and inter-system offset. + connected = calc_pvt([gps_l1; galileo_e1b_states(0.0Hz)]; kw...) + + # No GGTO to reconnect the split ⇒ fold: no IFB column. The L5 copies reproduce + # the Galileo transmit times exactly, so the fix is the *same* as the connected + # all-L1 reference — a finite, well-conditioned solution. (We compare to the + # reference rather than bounding the magnitudes: these fixtures' true + # inter-system offset is ~2.4e6 m, ~8 ms between the recordings. The degenerate + # solution the bug produced instead had a ~1e7 GDOP and a huge IFB that does not + # match the reference at all.) + gal_l5 = map(as_e5a, galileo_e1b_states(0.0Hz)) # Galileo on L5 only + pvt = calc_pvt([gps_l1; gal_l5]; kw...) + @test isempty(pvt.inter_frequency_biases) # L5 IFB folded into the Galileo clock + @test length(pvt.sats) == length(gps_l1) + length(gal_l5) + @test pvt.inter_system_biases[GST()] ≈ connected.inter_system_biases[GST()] rtol = 1e-6 + @test isfinite(pvt.dop.GDOP) + @test pvt.dop.GDOP ≈ connected.dop.GDOP rtol = 1e-6 + @test norm(pvt.position - connected.position) < 1e-3 + @test all(isfinite, [info.residual for info in values(pvt.sats)]) + + # With a (correct) broadcast GGTO, the collapse reconnects the bands: a clean + # inter-frequency bias is recovered and the inter-system bias comes from the GGTO. + true_isb = connected.inter_system_biases[GST()] + gal_l5_ggto = map(s -> as_e5a(s; ggto = -true_isb / C), galileo_e1b_states(0.0Hz)) + pvt_ggto = calc_pvt([gps_l1; gal_l5_ggto]; kw...) + @test pvt_ggto.reference_system == GPST() # Galileo collapsed onto GPS + @test haskey(pvt_ggto.inter_frequency_biases, :L5) # reconnected ⇒ IFB observable + @test pvt_ggto.inter_system_biases[GST()] ≈ true_isb atol = 5 + @test abs(pvt_ggto.inter_frequency_biases[:L5]) < 5 + @test isfinite(pvt_ggto.dop.GDOP) && 0 < pvt_ggto.dop.GDOP < 1e4 + @test norm(pvt_ggto.position - connected.position) < 10 + end +end diff --git a/test/ionosphere.jl b/test/ionosphere.jl index cf15122..c4d9303 100644 --- a/test/ionosphere.jl +++ b/test/ionosphere.jl @@ -221,10 +221,11 @@ end end @testset "GST week/TOW → day-of-year and UT" begin - # 1 week + 12 h past the GST epoch (1999-08-22 00:00 UTC) → 1999-08-29, 12:00 + # 1 week + 12 h past the GST epoch (1999-08-21T23:59:47 UTC) → 1999-08-29 + # at 11:59:47 (the epoch is 13 s before midnight, so 13 s before noon here). doy, ut = PositionVelocityTime._galileo_doy_and_ut(1, 12 * 3600) @test doy == dayofyear(Date(1999, 8, 29)) - @test ut ≈ 12.0 + @test ut ≈ 11 + 59 / 60 + 47 / 3600 end @testset "selection and delay from a Galileo decoder" begin diff --git a/test/pvt.jl b/test/pvt.jl index f2d59b4..a892e77 100644 --- a/test/pvt.jl +++ b/test/pvt.jl @@ -1,477 +1,6 @@ @testset "PVT Galileo E1B with frequency offset of $freq_offset" for freq_offset in (0.0Hz, 500Hz, -1000Hz) galileo_e1b = GalileoE1B() - states = [ - SatelliteState(; - decoder = GNSSDecoderState( - 2, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 1.885093958164237, - e = 0.0002336719771847129, - sqrt_A = 5440.601516723633, - Ω_0 = 0.43151849653459906, - i_0 = 0.9782668574498574, - ω = 0.18081747042485646, - i_dot = -3.6965825489294766e-10, - Ω_dot = -5.353794435599309e-9, - Δn = 3.053341469642328e-9, - C_uc = 1.9334256649017334e-6, - C_us = 8.616596460342407e-6, - C_rc = 163.65625, - C_rs = 40.46875, - C_ic = -4.284083843231201e-8, - C_is = 7.264316082000732e-8, - t_0c = 132000.0, - a_f0 = 0.00022286397870630026, - a_f1 = 2.5721647034515627e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = -2.0954757928848267e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 1.885093958164237, - e = 0.0002336719771847129, - sqrt_A = 5440.601516723633, - Ω_0 = 0.43151849653459906, - i_0 = 0.9782668574498574, - ω = 0.18081747042485646, - i_dot = -3.6965825489294766e-10, - Ω_dot = -5.353794435599309e-9, - Δn = 3.053341469642328e-9, - C_uc = 1.9334256649017334e-6, - C_us = 8.616596460342407e-6, - C_rc = 163.65625, - C_rs = 40.46875, - C_ic = -4.284083843231201e-8, - C_is = 7.264316082000732e-8, - t_0c = 132000.0, - a_f0 = 0.00022286397870630026, - a_f1 = 2.5721647034515627e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = -2.0954757928848267e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1549, - false, - ), - system = galileo_e1b, - carrier_doppler = 1617.3312825078192Hz + freq_offset, - code_phase = 2216.5793761534956, - carrier_phase = -1.461823180908076, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 4, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 128400.0, - M_0 = 1.1883716376830096, - e = 0.0003440612927079201, - sqrt_A = 5440.625782012939, - Ω_0 = -1.666444269041331, - i_0 = 0.9544851798532423, - ω = -1.401875745066068, - i_dot = 4.318037006430664e-10, - Ω_dot = -5.442726711414135e-9, - Δn = 3.016554223020131e-9, - C_uc = -2.2649765014648438e-6, - C_us = 9.763985872268677e-6, - C_rc = 128.46875, - C_rs = -51.40625, - C_ic = 5.029141902923584e-8, - C_is = -2.2351741790771484e-8, - t_0c = 128400.0, - a_f0 = -0.0007824758649803698, - a_f1 = -8.01492205937393e-12, - a_f2 = 0.0, - IOD_nav1 = 0x0000000000000056, - IOD_nav2 = 0x0000000000000056, - IOD_nav3 = 0x0000000000000056, - IOD_nav4 = 0x0000000000000056, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -3.026798367500305e-9, - broadcast_group_delay_e1_e5b = -3.4924596548080444e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 128400.0, - M_0 = 1.1883716376830096, - e = 0.0003440612927079201, - sqrt_A = 5440.625782012939, - Ω_0 = -1.666444269041331, - i_0 = 0.9544851798532423, - ω = -1.401875745066068, - i_dot = 4.318037006430664e-10, - Ω_dot = -5.442726711414135e-9, - Δn = 3.016554223020131e-9, - C_uc = -2.2649765014648438e-6, - C_us = 9.763985872268677e-6, - C_rc = 128.46875, - C_rs = -51.40625, - C_ic = 5.029141902923584e-8, - C_is = -2.2351741790771484e-8, - t_0c = 128400.0, - a_f0 = -0.0007824758649803698, - a_f1 = -8.01492205937393e-12, - a_f2 = 0.0, - IOD_nav1 = 0x0000000000000056, - IOD_nav2 = 0x0000000000000056, - IOD_nav3 = 0x0000000000000056, - IOD_nav4 = 0x0000000000000056, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -3.026798367500305e-9, - broadcast_group_delay_e1_e5b = -3.4924596548080444e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1546, - true, - ), - system = galileo_e1b, - carrier_doppler = 4055.9318556579988Hz + freq_offset, - code_phase = 48.75149191469789, - carrier_phase = -2.3877702498883373, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 11, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.7593379982069668, - e = 0.0005043556448072195, - sqrt_A = 5440.614900588989, - Ω_0 = 2.515118401165434, - i_0 = 0.9919634586882758, - ω = 0.7810709950849496, - i_dot = -1.821504444400032e-11, - Ω_dot = -5.659164298336962e-9, - Δn = 2.699398155054008e-9, - C_uc = 6.221234798431396e-7, - C_us = 2.125278115272522e-6, - C_rc = 311.3125, - C_rs = 12.25, - C_ic = -2.9802322387695312e-8, - C_is = 5.4016709327697754e-8, - t_0c = 132000.0, - a_f0 = 0.005911215674132109, - a_f1 = -4.936850928061176e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.3271346688270569e-8, - broadcast_group_delay_e1_e5b = -1.4435499906539917e-8, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.7593379982069668, - e = 0.0005043556448072195, - sqrt_A = 5440.614900588989, - Ω_0 = 2.515118401165434, - i_0 = 0.9919634586882758, - ω = 0.7810709950849496, - i_dot = -1.821504444400032e-11, - Ω_dot = -5.659164298336962e-9, - Δn = 2.699398155054008e-9, - C_uc = 6.221234798431396e-7, - C_us = 2.125278115272522e-6, - C_rc = 311.3125, - C_rs = 12.25, - C_ic = -2.9802322387695312e-8, - C_is = 5.4016709327697754e-8, - t_0c = 132000.0, - a_f0 = 0.005911215674132109, - a_f1 = -4.936850928061176e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = -1.3271346688270569e-8, - broadcast_group_delay_e1_e5b = -1.4435499906539917e-8, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1551, - false, - ), - system = galileo_e1b, - carrier_doppler = 209.7024609093128Hz + freq_offset, - code_phase = 69.54389556899758, - carrier_phase = -1.4138935647217596, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 25, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = -2.0355462626837646, - e = 0.00014415336772799492, - sqrt_A = 5440.611436843872, - Ω_0 = 0.42869178039052547, - i_0 = 0.9778386598651378, - ω = -1.4136735049701232, - i_dot = -3.982308736286344e-10, - Ω_dot = -5.379866950195624e-9, - Δn = 3.036555056135112e-9, - C_uc = 1.9241124391555786e-6, - C_us = 8.305534720420837e-6, - C_rc = 172.59375, - C_rs = 40.625, - C_ic = -7.450580596923828e-8, - C_is = 3.3527612686157227e-8, - t_0c = 132000.0, - a_f0 = -0.000535494415089488, - a_f1 = -1.2221335055073723e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 3.4924596548080444e-9, - broadcast_group_delay_e1_e5b = 3.958120942115784e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = -2.0355462626837646, - e = 0.00014415336772799492, - sqrt_A = 5440.611436843872, - Ω_0 = 0.42869178039052547, - i_0 = 0.9778386598651378, - ω = -1.4136735049701232, - i_dot = -3.982308736286344e-10, - Ω_dot = -5.379866950195624e-9, - Δn = 3.036555056135112e-9, - C_uc = 1.9241124391555786e-6, - C_us = 8.305534720420837e-6, - C_rc = 172.59375, - C_rs = 40.625, - C_ic = -7.450580596923828e-8, - C_is = 3.3527612686157227e-8, - t_0c = 132000.0, - a_f0 = -0.000535494415089488, - a_f1 = -1.2221335055073723e-12, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 3.4924596548080444e-9, - broadcast_group_delay_e1_e5b = 3.958120942115784e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1548, - true, - ), - system = galileo_e1b, - carrier_doppler = -974.6289079820336Hz + freq_offset, - code_phase = 1701.8894620076721, - carrier_phase = -1.721713905186919, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 30, - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.43031807277213097, - e = 0.00031052413396537304, - sqrt_A = 5440.595668792725, - Ω_0 = 0.43339143836728705, - i_0 = 0.9757531121479899, - ω = 0.869054289656611, - i_dot = -3.9465929628667357e-10, - Ω_dot = -5.42129724736237e-9, - Δn = 3.102986394695584e-9, - C_uc = 2.089887857437134e-6, - C_us = 8.553266525268555e-6, - C_rc = 162.5, - C_rs = 45.375, - C_ic = 8.754432201385498e-8, - C_is = 8.195638656616211e-8, - t_0c = 132000.0, - a_f0 = 0.002901351428590715, - a_f1 = -3.0667024475405924e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = 1.6298145055770874e-9, - ), - GNSSDecoder.GalileoE1BData( - WN = 1136, - TOW = 132769, - t_0e = 132000.0, - M_0 = 0.43031807277213097, - e = 0.00031052413396537304, - sqrt_A = 5440.595668792725, - Ω_0 = 0.43339143836728705, - i_0 = 0.9757531121479899, - ω = 0.869054289656611, - i_dot = -3.9465929628667357e-10, - Ω_dot = -5.42129724736237e-9, - Δn = 3.102986394695584e-9, - C_uc = 2.089887857437134e-6, - C_us = 8.553266525268555e-6, - C_rc = 162.5, - C_rs = 45.375, - C_ic = 8.754432201385498e-8, - C_is = 8.195638656616211e-8, - t_0c = 132000.0, - a_f0 = 0.002901351428590715, - a_f1 = -3.0667024475405924e-11, - a_f2 = 0.0, - IOD_nav1 = 0x000000000000005c, - IOD_nav2 = 0x000000000000005c, - IOD_nav3 = 0x000000000000005c, - IOD_nav4 = 0x000000000000005c, - num_pages_after_last_TOW = 5, - num_bits_after_valid_syncro_sequence_after_last_TOW = 0, - signal_health_e1b = GNSSDecoder.signal_ok, - signal_health_e5b = GNSSDecoder.signal_ok, - data_validity_status_e1b = GNSSDecoder.navigation_data_valid, - data_validity_status_e5b = GNSSDecoder.navigation_data_valid, - broadcast_group_delay_e1_e5a = 1.6298145055770874e-9, - broadcast_group_delay_e1_e5b = 1.6298145055770874e-9, - ), - GNSSDecoder.GalileoE1BConstants( - 250, - 0x0160, - 10, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986004418e14, - -4.442807309e-10, - ), - GNSSDecoder.GalileoE1BCache(), - 1547, - true, - ), - system = galileo_e1b, - carrier_doppler = 4089.415808665647Hz + freq_offset, - code_phase = 4015.495436832823, - carrier_phase = -2.8554107708683047, - ), - ] + states = galileo_e1b_states(freq_offset) # Fixture data was recorded on 2021-05-31. Pin `approximate_year` so # this test stays valid as the wall clock drifts past 2030 (when the @@ -487,7 +16,7 @@ @test pvt.position ≈ ECEFfromLLA(wgs84)(expected_lla) rtol = 1e-8 @test pvt.time ≈ TAIEpoch(2021, 5, 31, 12, 53, 14.1183385390904732) @test pvt.velocity ≈ ECEF(0.0, 0.0, 0.0) atol = 9 - @test get_frequency_offset(pvt, get_center_frequency(galileo_e1b)) ≈ -(1675.63Hz + freq_offset) atol = 0.01Hz + @test pvt.relative_clock_drift * get_center_frequency(galileo_e1b) ≈ -(1675.63Hz + freq_offset) atol = 0.01Hz warm_pvt = calc_pvt( states, @@ -500,902 +29,9 @@ @test warm_pvt.time ≈ pvt.time @test warm_pvt.velocity ≈ pvt.velocity atol = 1e-6 end - @testset "PVT GPS L1 with frequency offset of $freq_offset" for freq_offset in (0.0Hz, 500Hz, -1000Hz) gpsl1 = GPSL1CA() - states = [ - SatelliteState(; - decoder = GNSSDecoderState( - 7, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001000110", - l2pcode = false, - T_GD = -1.1175870895385742e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 1.0231815394945443e-11, - a_f0 = 0.0001659449189901352, - IODE_Sub_2 = "01000110", - C_rs = 14.90625, - Δn = 4.9980653323400095e-9, - M_0 = -1.5018856847786306, - C_uc = 8.065253496170044e-7, - e = 0.014979799510911107, - C_us = 6.051734089851379e-6, - sqrt_A = 5153.747692108154, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 3.110617399215698e-7, - Ω_0 = -0.42148546787582664, - C_is = -5.21540641784668e-8, - i_0 = 0.9517858648559379, - C_rc = 250.375, - ω = -2.331033772387461, - Ω_dot = -8.050692486513945e-9, - IODE_Sub_3 = "01000110", - i_dot = 1.657211886669833e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001000110", - l2pcode = false, - T_GD = -1.1175870895385742e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 1.0231815394945443e-11, - a_f0 = 0.0001659449189901352, - IODE_Sub_2 = "01000110", - C_rs = 14.90625, - Δn = 4.9980653323400095e-9, - M_0 = -1.5018856847786306, - C_uc = 8.065253496170044e-7, - e = 0.014979799510911107, - C_us = 6.051734089851379e-6, - sqrt_A = 5153.747692108154, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 3.110617399215698e-7, - Ω_0 = -0.42148546787582664, - C_is = -5.21540641784668e-8, - i_0 = 0.9517858648559379, - C_rc = 250.375, - ω = -2.331033772387461, - Ω_dot = -8.050692486513945e-9, - IODE_Sub_3 = "01000110", - i_dot = 1.657211886669833e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 2669.8440799388595Hz + freq_offset, - code_phase = 4876.431542382193, - carrier_phase = 0.09402551301430394, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 8, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001100111", - l2pcode = false, - T_GD = 5.122274160385132e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.4779288903810084e-12, - a_f0 = -2.3324042558670044e-5, - IODE_Sub_2 = "01100111", - C_rs = 84.21875, - Δn = 4.435899058715372e-9, - M_0 = 1.3315461632138303, - C_uc = 4.639849066734314e-6, - e = 0.006349898059852421, - C_us = 6.3907355070114136e-6, - sqrt_A = 5153.606374740601, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 6.891787052154541e-8, - Ω_0 = 1.6470055929195884, - C_is = 7.450580596923828e-8, - i_0 = 0.9671090437449873, - C_rc = 257.125, - ω = 0.05450158504406715, - Ω_dot = -8.087122575401946e-9, - IODE_Sub_3 = "01100111", - i_dot = -1.353627812603161e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001100111", - l2pcode = false, - T_GD = 5.122274160385132e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.4779288903810084e-12, - a_f0 = -2.3324042558670044e-5, - IODE_Sub_2 = "01100111", - C_rs = 84.21875, - Δn = 4.435899058715372e-9, - M_0 = 1.3315461632138303, - C_uc = 4.639849066734314e-6, - e = 0.006349898059852421, - C_us = 6.3907355070114136e-6, - sqrt_A = 5153.606374740601, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 6.891787052154541e-8, - Ω_0 = 1.6470055929195884, - C_is = 7.450580596923828e-8, - i_0 = 0.9671090437449873, - C_rc = 257.125, - ω = 0.05450158504406715, - Ω_dot = -8.087122575401946e-9, - IODE_Sub_3 = "01100111", - i_dot = -1.353627812603161e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 4704.3549972665805Hz + freq_offset, - code_phase = 8455.107739656896, - carrier_phase = 2.7614518715603946, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 10, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000101000", - l2pcode = false, - T_GD = 2.3283064365386963e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -7.617018127348274e-12, - a_f0 = -0.0001329910010099411, - IODE_Sub_2 = "00101000", - C_rs = -89.25, - Δn = 4.016595878769169e-9, - M_0 = -2.941058160546095, - C_uc = -4.645437002182007e-6, - e = 0.006653358926996589, - C_us = 7.499009370803833e-6, - sqrt_A = 5153.6786251068115, - t_0e = 136800, - fit_interval = false, - AODO = 20, - C_ic = -3.5390257835388184e-8, - Ω_0 = -2.5268399079275254, - C_is = -6.146728992462158e-8, - i_0 = 0.969412731565689, - C_rc = 239.09375, - ω = -2.5641372296478258, - Ω_dot = -7.765323456891273e-9, - IODE_Sub_3 = "00101000", - i_dot = 3.893019302737323e-11, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000101000", - l2pcode = false, - T_GD = 2.3283064365386963e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -7.617018127348274e-12, - a_f0 = -0.0001329910010099411, - IODE_Sub_2 = "00101000", - C_rs = -89.25, - Δn = 4.016595878769169e-9, - M_0 = -2.941058160546095, - C_uc = -4.645437002182007e-6, - e = 0.006653358926996589, - C_us = 7.499009370803833e-6, - sqrt_A = 5153.6786251068115, - t_0e = 136800, - fit_interval = false, - AODO = 20, - C_ic = -3.5390257835388184e-8, - Ω_0 = -2.5268399079275254, - C_is = -6.146728992462158e-8, - i_0 = 0.969412731565689, - C_rc = 239.09375, - ω = -2.5641372296478258, - Ω_dot = -7.765323456891273e-9, - IODE_Sub_3 = "00101000", - i_dot = 3.893019302737323e-11, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = 4603.179391134832Hz + freq_offset, - code_phase = 10510.15670303955, - carrier_phase = -0.7447786034769108, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 15, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001011001", - l2pcode = false, - T_GD = -1.0710209608078003e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 2.6147972675971687e-12, - a_f0 = -0.00014561135321855545, - IODE_Sub_2 = "01011001", - C_rs = -30.5625, - Δn = 5.182001565450993e-9, - M_0 = 0.38753804422936994, - C_uc = -1.471489667892456e-6, - e = 0.013163934461772442, - C_us = 9.061768651008606e-6, - sqrt_A = 5153.620079040527, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.695007085800171e-7, - Ω_0 = -1.5933596086804434, - C_is = 1.6205012798309326e-7, - i_0 = 0.9286304727294836, - C_rc = 190.6875, - ω = 0.98557755063243, - Ω_dot = -8.41785063726752e-9, - IODE_Sub_3 = "01011001", - i_dot = 3.010839699272994e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001011001", - l2pcode = false, - T_GD = -1.0710209608078003e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 2.6147972675971687e-12, - a_f0 = -0.00014561135321855545, - IODE_Sub_2 = "01011001", - C_rs = -30.5625, - Δn = 5.182001565450993e-9, - M_0 = 0.38753804422936994, - C_uc = -1.471489667892456e-6, - e = 0.013163934461772442, - C_us = 9.061768651008606e-6, - sqrt_A = 5153.620079040527, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.695007085800171e-7, - Ω_0 = -1.5933596086804434, - C_is = 1.6205012798309326e-7, - i_0 = 0.9286304727294836, - C_rc = 190.6875, - ω = 0.98557755063243, - Ω_dot = -8.41785063726752e-9, - IODE_Sub_3 = "01011001", - i_dot = 3.010839699272994e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 3144.174219887768Hz + freq_offset, - code_phase = 3618.5099300503684, - carrier_phase = -0.22375187424152987, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 16, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000100010", - l2pcode = false, - T_GD = -1.0244548320770264e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.0254023992456496e-12, - a_f0 = -0.0003338884562253952, - IODE_Sub_2 = "00100010", - C_rs = 37.0, - Δn = 4.26732060817482e-9, - M_0 = 2.20050848506247, - C_uc = 1.8272548913955688e-6, - e = 0.012199128163047135, - C_us = 9.119510650634766e-6, - sqrt_A = 5153.70516204834, - t_0e = 136800, - fit_interval = false, - AODO = 22, - C_ic = -2.3655593395233154e-7, - Ω_0 = 0.7074087615001045, - C_is = -1.30385160446167e-7, - i_0 = 0.9738089978878275, - C_rc = 212.75, - ω = 0.6779943621465904, - Ω_dot = -7.692463279115272e-9, - IODE_Sub_3 = "00100010", - i_dot = -2.95012288445966e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0000100010", - l2pcode = false, - T_GD = -1.0244548320770264e-8, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.0254023992456496e-12, - a_f0 = -0.0003338884562253952, - IODE_Sub_2 = "00100010", - C_rs = 37.0, - Δn = 4.26732060817482e-9, - M_0 = 2.20050848506247, - C_uc = 1.8272548913955688e-6, - e = 0.012199128163047135, - C_us = 9.119510650634766e-6, - sqrt_A = 5153.70516204834, - t_0e = 136800, - fit_interval = false, - AODO = 22, - C_ic = -2.3655593395233154e-7, - Ω_0 = 0.7074087615001045, - C_is = -1.30385160446167e-7, - i_0 = 0.9738089978878275, - C_rc = 212.75, - ω = 0.6779943621465904, - Ω_dot = -7.692463279115272e-9, - IODE_Sub_3 = "00100010", - i_dot = -2.95012288445966e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 595.7926881306387Hz + freq_offset, - code_phase = 17600.66651489137, - carrier_phase = 2.59152430602131, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 18, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "1111100001", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.3642420526593924e-12, - a_f0 = 0.0003486964851617813, - IODE_Sub_2 = "11100001", - C_rs = -15.3125, - Δn = 4.35303846438188e-9, - M_0 = -0.6041391930737793, - C_uc = -8.717179298400879e-7, - e = 0.0014569575432687998, - C_us = 3.339722752571106e-6, - sqrt_A = 5153.662895202637, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 2.60770320892334e-8, - Ω_0 = 2.733796625968394, - C_is = -4.284083843231201e-8, - i_0 = 0.9683542210973364, - C_rc = 321.59375, - ω = 2.894680446328142, - Ω_dot = -8.184269479103281e-9, - IODE_Sub_3 = "11100001", - i_dot = -1.0071848104329588e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "1111100001", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -1.3642420526593924e-12, - a_f0 = 0.0003486964851617813, - IODE_Sub_2 = "11100001", - C_rs = -15.3125, - Δn = 4.35303846438188e-9, - M_0 = -0.6041391930737793, - C_uc = -8.717179298400879e-7, - e = 0.0014569575432687998, - C_us = 3.339722752571106e-6, - sqrt_A = 5153.662895202637, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = 2.60770320892334e-8, - Ω_0 = 2.733796625968394, - C_is = -4.284083843231201e-8, - i_0 = 0.9683542210973364, - C_rc = 321.59375, - ω = 2.894680446328142, - Ω_dot = -8.184269479103281e-9, - IODE_Sub_3 = "11100001", - i_dot = -1.0071848104329588e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = -794.0022484221436Hz + freq_offset, - code_phase = 14504.587373634655, - carrier_phase = -0.7416765461612318, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 23, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0011101110", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -3.751665644813329e-12, - a_f0 = 0.00010169018059968948, - IODE_Sub_2 = "11101110", - C_rs = -92.40625, - Δn = 4.153030133232073e-9, - M_0 = -1.3144457890174628, - C_uc = -4.732981324195862e-6, - e = 0.0012323472183197737, - C_us = 7.256865501403809e-6, - sqrt_A = 5153.657176971436, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -9.872019290924072e-8, - Ω_0 = -2.5503370450318865, - C_is = 2.9802322387695312e-8, - i_0 = 0.9644177455387573, - C_rc = 242.5, - ω = 2.603874239368067, - Ω_dot = -7.890328663859904e-9, - IODE_Sub_3 = "11101110", - i_dot = 3.107272287505937e-11, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0011101110", - l2pcode = false, - T_GD = -8.381903171539307e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -3.751665644813329e-12, - a_f0 = 0.00010169018059968948, - IODE_Sub_2 = "11101110", - C_rs = -92.40625, - Δn = 4.153030133232073e-9, - M_0 = -1.3144457890174628, - C_uc = -4.732981324195862e-6, - e = 0.0012323472183197737, - C_us = 7.256865501403809e-6, - sqrt_A = 5153.657176971436, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -9.872019290924072e-8, - Ω_0 = -2.5503370450318865, - C_is = 2.9802322387695312e-8, - i_0 = 0.9644177455387573, - C_rc = 242.5, - ω = 2.603874239368067, - Ω_dot = -7.890328663859904e-9, - IODE_Sub_3 = "11101110", - i_dot = 3.107272287505937e-11, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = 2826.783251528247Hz + freq_offset, - code_phase = 14394.465193705755, - carrier_phase = -0.7597910878699417, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 26, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001101110", - l2pcode = false, - T_GD = 6.984919309616089e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 4.888534022029489e-12, - a_f0 = 9.242305532097816e-5, - IODE_Sub_2 = "01101110", - C_rs = 29.53125, - Δn = 5.002351225150362e-9, - M_0 = 3.0615288470223163, - C_uc = 1.5329569578170776e-6, - e = 0.00589060562197119, - C_us = 8.42846930027008e-6, - sqrt_A = 5153.568691253662, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.043081283569336e-7, - Ω_0 = 0.5730118600154152, - C_is = 1.30385160446167e-8, - i_0 = 0.942073253292395, - C_rc = 208.875, - ω = 0.3072108597425673, - Ω_dot = -8.057478483463671e-9, - IODE_Sub_3 = "01101110", - i_dot = -3.328710082707509e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001101110", - l2pcode = false, - T_GD = 6.984919309616089e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = 4.888534022029489e-12, - a_f0 = 9.242305532097816e-5, - IODE_Sub_2 = "01101110", - C_rs = 29.53125, - Δn = 5.002351225150362e-9, - M_0 = 3.0615288470223163, - C_uc = 1.5329569578170776e-6, - e = 0.00589060562197119, - C_us = 8.42846930027008e-6, - sqrt_A = 5153.568691253662, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -1.043081283569336e-7, - Ω_0 = 0.5730118600154152, - C_is = 1.30385160446167e-8, - i_0 = 0.942073253292395, - C_rc = 208.875, - ω = 0.3072108597425673, - Ω_dot = -8.057478483463671e-9, - IODE_Sub_3 = "01101110", - i_dot = -3.328710082707509e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - true, - ), - system = gpsl1, - carrier_doppler = -1083.2317687377372Hz + freq_offset, - code_phase = 13905.233170289455, - carrier_phase = -2.1444956909602526, - ), - SatelliteState(; - decoder = GNSSDecoderState( - 27, - GNSSDecoder.GPSL1CAData( - last_subframe_id = 4, - integrity_status_flag = false, - TOW = nothing, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001001110", - l2pcode = false, - T_GD = 1.862645149230957e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.366462912410498e-12, - a_f0 = -0.0001393994316458702, - IODE_Sub_2 = "01001110", - C_rs = 82.25, - Δn = 4.257677349351526e-9, - M_0 = 1.25128819523303, - C_uc = 4.505738615989685e-6, - e = 0.00944566575344652, - C_us = 6.81169331073761e-6, - sqrt_A = 5153.686637878418, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -5.029141902923584e-8, - Ω_0 = 1.66429757866306, - C_is = 1.601874828338623e-7, - i_0 = 0.9756900633046096, - C_rc = 257.375, - ω = 0.6281523291801417, - Ω_dot = -8.07676500111026e-9, - IODE_Sub_3 = "01001110", - i_dot = -1.2286226056345314e-10, - ), - GNSSDecoder.GPSL1CAData( - last_subframe_id = 3, - integrity_status_flag = false, - TOW = 132768, - alert_flag = false, - anti_spoof_flag = true, - trans_week = 112, - codeonl2 = 1, - ura = 2.0, - svhealth = "000000", - IODC = "0001001110", - l2pcode = false, - T_GD = 1.862645149230957e-9, - t_0c = 136800, - a_f2 = 0.0, - a_f1 = -6.366462912410498e-12, - a_f0 = -0.0001393994316458702, - IODE_Sub_2 = "01001110", - C_rs = 82.25, - Δn = 4.257677349351526e-9, - M_0 = 1.25128819523303, - C_uc = 4.505738615989685e-6, - e = 0.00944566575344652, - C_us = 6.81169331073761e-6, - sqrt_A = 5153.686637878418, - t_0e = 136800, - fit_interval = false, - AODO = 31, - C_ic = -5.029141902923584e-8, - Ω_0 = 1.66429757866306, - C_is = 1.601874828338623e-7, - i_0 = 0.9756900633046096, - C_rc = 257.375, - ω = 0.6281523291801417, - Ω_dot = -8.07676500111026e-9, - IODE_Sub_3 = "01001110", - i_dot = -1.2286226056345314e-10, - ), - GNSSDecoder.GPSL1CAConstants( - 300, - 0x8b, - 8, - 30, - 3.1415926535898, - 7.2921151467e-5, - 2.99792458e8, - 3.986005e14, - -4.442807633e-10, - ), - GNSSDecoder.GPSL1CACache(), - 360, - false, - ), - system = gpsl1, - carrier_doppler = 3393.7445379085816Hz + freq_offset, - code_phase = 16861.02713837273, - carrier_phase = -0.9185401811870872, - ), - ] + states = gps_l1_states(freq_offset) # Fixture data was recorded on 2021-05-31. See the note on the # Galileo testset above for why we pin `approximate_year`. @@ -1409,7 +45,7 @@ end @test pvt.position ≈ ECEFfromLLA(wgs84)(expected_lla) rtol = 1e-8 @test pvt.time ≈ TAIEpoch(2021, 5, 31, 12, 53, 14.1491024351271335) @test pvt.velocity ≈ ECEF(0.0, 0.0, 0.0) atol = 2.5 - @test get_frequency_offset(pvt, get_center_frequency(gpsl1)) ≈ -(1632.59Hz + freq_offset) atol = 0.01Hz + @test pvt.relative_clock_drift * get_center_frequency(gpsl1) ≈ -(1632.59Hz + freq_offset) atol = 0.01Hz warm_pvt = calc_pvt( states, @@ -1422,3 +58,198 @@ end @test warm_pvt.time ≈ pvt.time @test warm_pvt.velocity ≈ pvt.velocity atol = 1e-6 end + +# Injects synthetic GGTO parameters into a Galileo SatelliteState's decoder. +function with_ggto(state; A_0G, A_1G = 0.0, t_0G = 0, WN_0G = 0) + d = state.decoder + new_data = GNSSDecoder.GalileoE1BData(d.data; A_0G, A_1G, t_0G, WN_0G) + new_raw = GNSSDecoder.GalileoE1BData(d.raw_data; A_0G, A_1G, t_0G, WN_0G) + new_decoder = GNSSDecoder.GNSSDecoderState(d; raw_data = new_raw, data = new_data) + SatelliteState(; + decoder = new_decoder, + system = state.system, + code_phase = state.code_phase, + carrier_doppler = state.carrier_doppler, + carrier_phase = state.carrier_phase, + ) +end + +# Relabels a SatelliteState's decoder PRN. The PRN is not used in the solve, only +# as the `sats`-dict key, so this isolates the (system, PRN) keying behaviour. +function with_prn(state, prn) + d = state.decoder + new_decoder = GNSSDecoder.GNSSDecoderState(; + prn = prn, + raw_data = d.raw_data, + data = d.data, + constants = d.constants, + cache = d.cache, + num_bits_after_valid_syncro_sequence = d.num_bits_after_valid_syncro_sequence, + is_shifted_by_180_degrees = d.is_shifted_by_180_degrees, + ) + SatelliteState(; + decoder = new_decoder, + system = state.system, + code_phase = state.code_phase, + carrier_doppler = state.carrier_doppler, + carrier_phase = state.carrier_phase, + ) +end + +@testset "PVT GPS L1 + Galileo E1B combined, frequency offset $freq_offset" for freq_offset in (0.0Hz, 500Hz, -1000Hz) + states = [gps_l1_states(freq_offset); galileo_e1b_states(freq_offset)] + + # Independent inter-system-bias solve: 8 GPS + 5 Galileo satellites, with one + # clock bias per system (3 + 2 unknowns). + pvt = calc_pvt(states; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + expected_pos = ECEF(4.0186793403460276e6, 427032.98199905816, 4.918251309950126e6) + @test pvt.position ≈ expected_pos rtol = 1e-8 + @test pvt.velocity ≈ ECEF(-1.4405743822415678, 0.5393693783528187, -2.135825176671574) atol = 1e-3 + @test pvt.time ≈ TAIEpoch(2021, 5, 31, 12, 53, 14.285) + + # One clock bias per GNSS; the reference (GPS, most satellites) bias is + # reported as time_correction, with Galileo's offset relative to it. + @test pvt.reference_system == GPST() + @test Set(keys(pvt.inter_system_biases)) == Set([GST()]) + + # The combined fix agrees with each single-system fix and with the + # GPS-anchored time scale. + gps_only = calc_pvt(gps_l1_states(freq_offset); approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + gal_only = calc_pvt(galileo_e1b_states(freq_offset); approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + @test norm(pvt.position - gps_only.position) < 5 + @test norm(pvt.position - gal_only.position) < 5 + @test pvt.time ≈ gps_only.time + + # A single receiver clock drift is estimated for the whole constellation + # (one oscillator), so the combined drift agrees with each single-system drift + # — to well within the inter-system spread, not split per system. + @test pvt.relative_clock_drift ≈ gps_only.relative_clock_drift atol = 5e-8 + @test pvt.relative_clock_drift ≈ gal_only.relative_clock_drift atol = 5e-8 + + # Satellites are keyed by (signal, PRN), so both constellations are represented + # and nothing is merged or dropped across systems: the combined count equals the + # sum of the single-system counts (PRNs here don't overlap). + @test all(k -> k isa Tuple{Symbol,Int}, keys(pvt.sats)) + @test Set(first(k) for k in keys(pvt.sats)) == Set([:GPSL1CA, :GalileoE1B]) + @test length(pvt.sats) == + length(gps_only.sats) + length(gal_only.sats) + let (sig, prn) = first(keys(pvt.sats)) + @test get_sat_info(pvt, sig, prn) === pvt.sats[(sig, prn)] + end + # A satellite not used in the fix returns `nothing` rather than throwing. + @test get_sat_info(pvt, :GPSL1CA, 999) === nothing + + # Per-satellite post-fit residuals are exposed via SatInfo (modeled − measured + # pseudorange, metres); finite and small for a good fix. + resids = [info.residual for info in values(pvt.sats)] + @test length(resids) == length(pvt.sats) + @test all(isfinite, resids) + @test maximum(abs, resids) < 10 + + warm_pvt = calc_pvt(states, pvt; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + @test get_LLA(warm_pvt) ≈ get_LLA(pvt) + @test warm_pvt.time ≈ pvt.time + @test warm_pvt.velocity ≈ pvt.velocity atol = 1e-6 +end + +@testset "PVT GPS + Galileo GGTO fallback" begin + C = 299792458.0 + gps = gps_l1_states(0.0Hz) + gal = galileo_e1b_states(0.0Hz) + + # The full independent solution provides the reference position and the + # inter-system time offset that the GGTO must encode. + reference = calc_pvt([gps; gal]; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + gps_only = calc_pvt(gps; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + # reference_system is GPS (most satellites), so the Galileo inter-system bias + # is Galileo − GPS = −c·(GST − GPST). The physical broadcast GGTO that would + # reproduce it is its negation, in seconds. + Δ = -reference.inter_system_biases[GST()] / C + + # 3 GPS + 1 Galileo: the independent solve needs 3 + 2 = 5 satellites, so + # without GGTO the constellation is under-determined and calc_pvt returns the + # (origin) previous solution. + subset = [gps[1:3]; gal[1:1]] + @test calc_pvt(subset; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false).position == ECEF(0, 0, 0) + + # With the GGTO available the Galileo clock bias collapses onto GPS, so a + # 4-satellite fix becomes possible and reproduces the full-constellation + # position. A wrong offset sign would corrupt the single Galileo measurement + # (here c·Δ ≈ 2.4e6 m), so reproducing the reference also pins the sign. + subset_ggto = [gps[1:3]; [with_ggto(gal[1]; A_0G = Δ)]] + pvt = calc_pvt(subset_ggto; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + @test pvt.position != ECEF(0, 0, 0) + @test norm(pvt.position - reference.position) < 10 + @test norm(pvt.position - gps_only.position) < 10 + @test pvt.reference_system == GPST() + @test Set(keys(pvt.inter_system_biases)) == Set([GST()]) + + # A single Galileo satellite carrying the GGTO is enough to collapse the + # whole Galileo set: here only the first of two Galileo satellites has it. + @test !PositionVelocityTime.ggto_available(gal[2].decoder) + mixed = [gps[1:2]; [with_ggto(gal[1]; A_0G = Δ), gal[2]]] + pvt_mixed = calc_pvt(mixed; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + @test pvt_mixed.position != ECEF(0, 0, 0) + @test norm(pvt_mixed.position - reference.position) < 50 + + # The GGTO offset is applied with the exact sign and magnitude: the Galileo + # inter-system bias (relative to the GPS reference) equals −c·A_0G, because a + # Galileo measurement is moved into the GPS frame by subtracting GST − GPST. + A_big = 1.0e-6 + big = calc_pvt([gps[1:3]; [with_ggto(gal[1]; A_0G = A_big)]]; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + @test big.inter_system_biases[GST()] ≈ -C * A_big rtol = 1e-6 + + # calc_ggto_offset evaluates the OS SIS ICD word-type-10 polynomial, taking + # the reference week difference modulo 64. + g = with_ggto(gal[1]; A_0G = 5.0e-9, A_1G = 1.0e-15, t_0G = 100, WN_0G = 1134) + @test PositionVelocityTime.calc_ggto_offset(g.decoder, 132000.0) ≈ + 5.0e-9 + 1.0e-15 * (132000.0 - 100 + 604800 * mod(1136 - 1134, 64)) + @test PositionVelocityTime.ggto_available(g.decoder) + @test !PositionVelocityTime.ggto_available(gal[1].decoder) + @test !PositionVelocityTime.ggto_available(gps[1].decoder) +end + +@testset "PVT primary system is the most-populated GNSS" begin + # Galileo-majority independent mix (2 GPS + 5 Galileo): no GGTO collapse, so + # the primary system — which sets the reported time scale and time_correction + # — should be Galileo, the constellation with more satellites. + gps = gps_l1_states(0.0Hz) + gal = galileo_e1b_states(0.0Hz) + states = [gps[1:2]; gal] + pvt = calc_pvt(states; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + gal_only = calc_pvt(gal; approximate_year = 2021, enable_ionospheric_correction = false, enable_tropospheric_correction = false) + + @test pvt.reference_system == GST() + @test Set(keys(pvt.inter_system_biases)) == Set([GPST()]) + @test pvt.time ≈ gal_only.time + @test norm(pvt.position - gal_only.position) < 10 +end + +@testset "PVT sats keyed by (signal, PRN): same PRN across signals coexists" begin + gps = gps_l1_states(0.0Hz) + gal = galileo_e1b_states(0.0Hz) + healthy(s) = PositionVelocityTime.is_sat_healthy(s.decoder) + + # Pick a healthy satellite from each constellation and force the Galileo one to + # share the GPS satellite's PRN, so the only thing telling them apart is the + # signal tag in the key. + gps_h = first(s for s in gps if healthy(s)) + gal_h_idx = findfirst(healthy, gal) + shared_prn = gps_h.decoder.prn + collided = with_prn(gal[gal_h_idx], shared_prn) + states = [gps; collided; gal[setdiff(1:length(gal), gal_h_idx)]] + + baseline = calc_pvt([gps; gal]; approximate_year = 2021, + enable_ionospheric_correction = false, enable_tropospheric_correction = false) + pvt = calc_pvt(states; approximate_year = 2021, + enable_ionospheric_correction = false, enable_tropospheric_correction = false) + + # Both the GPS and the Galileo satellite with the shared PRN survive as + # distinct entries — with a bare-Int PRN key one would have overwritten the + # other and the used-satellite count would have dropped. + @test haskey(pvt.sats, (:GPSL1CA, shared_prn)) + @test haskey(pvt.sats, (:GalileoE1B, shared_prn)) + @test get_sat_info(pvt, :GPSL1CA, shared_prn) !== + get_sat_info(pvt, :GalileoE1B, shared_prn) + @test length(pvt.sats) == length(baseline.sats) +end diff --git a/test/pvt_integration.jl b/test/pvt_integration.jl index 4318507..f3995b6 100644 --- a/test/pvt_integration.jl +++ b/test/pvt_integration.jl @@ -239,14 +239,14 @@ else # Solve PVT. `approximate_year` resolves the GPS 1024-week rollover; the # recording is from 2014, not "now". pvt = calc_pvt(states; approximate_year = 2014) - @test get_num_used_sats(pvt) >= 4 + @test length(pvt.sats) >= 4 lla = get_LLA(pvt) @info "PVT fix" lat = lla.lat lon = lla.lon alt = lla.alt used = - get_num_used_sats(pvt) gdop = get_gdop(pvt) + length(pvt.sats) gdop = pvt.dop.GDOP ref_ecef = ECEFfromLLA(wgs84)(L125_REF_LLA) @test euclidean_distance(pvt.position, ref_ecef) < L125_REF_TOL_M - @test isfinite(get_gdop(pvt)) && get_gdop(pvt) > 0 + @test isfinite(pvt.dop.GDOP) && pvt.dop.GDOP > 0 # The decoded navigation time must place the recording on 2014-09-23 # (GPS week 1811) — a genuine, parity-/ephemeris-validated end-to-end diff --git a/test/pvt_iono_tropo.jl b/test/pvt_iono_tropo.jl index c192ffc..8702484 100644 --- a/test/pvt_iono_tropo.jl +++ b/test/pvt_iono_tropo.jl @@ -19,7 +19,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -65,7 +65,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -134,7 +134,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -180,7 +180,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -249,7 +249,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -295,7 +295,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -364,7 +364,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, @@ -410,7 +410,7 @@ const IONO_TROPO_GROUND_TRUTH = ECEFfromLLA(wgs84)(LLA(48.0, 11.0, 550.0)) trans_week = 58, codeonl2 = 1, ura = 2.0, - svhealth = "000000", + sv_health = "000000", IODC = "0000000000", l2pcode = false, T_GD = 0.0, diff --git a/test/runtests.jl b/test/runtests.jl index 7780e7c..c7ea0da 100755 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,10 +1,16 @@ -using Test, PositionVelocityTime, GNSSDecoder, AstroTime, GNSSSignals, Geodesy, Dates +using Test, PositionVelocityTime, GNSSDecoder, AstroTime, GNSSSignals, Geodesy, Dates, LinearAlgebra using Unitful: Hz include("aqua.jl") +include("fixtures.jl") include("sat_time.jl") include("pvt.jl") +include("dop.jl") +include("cnav.jl") +include("gps_l2c.jl") +include("galileo_e5a.jl") +include("inter_frequency_bias.jl") include("pvt_iono_tropo.jl") include("get_week.jl") include("tracking_ext.jl")