From a173098079fbff4a0bd0542ff7967399c62d27de Mon Sep 17 00:00:00 2001 From: Carlo Cena Date: Fri, 16 Oct 2026 13:34:29 +0200 Subject: [PATCH 1/5] [#1612] Compute spiceInterface GPS time from TAI and UTC Julian date from deltet --- docs/source/Support/bskKnownIssues.rst | 6 ++ .../1612-spice-gps-time.rst | 1 + .../msgPayloadDefC/SpiceTimeMsgPayload.h | 6 +- .../_UnitTest/test_spiceBufferOwnership.py | 4 +- .../_UnitTest/test_spiceTimeOutput.py | 77 +++++++++++++++++++ .../_UnitTest/test_unitSpice.py | 5 ++ .../spiceInterface/spiceInterface.cpp | 34 ++++---- .../spiceInterface/spiceInterface.h | 7 +- .../spiceInterface/spiceInterface.rst | 9 +++ 9 files changed, 122 insertions(+), 27 deletions(-) create mode 100644 docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst create mode 100644 src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py diff --git a/docs/source/Support/bskKnownIssues.rst b/docs/source/Support/bskKnownIssues.rst index a2de3599a9a..8ed1e419782 100644 --- a/docs/source/Support/bskKnownIssues.rst +++ b/docs/source/Support/bskKnownIssues.rst @@ -13,6 +13,12 @@ Version |release| ``PlanetRadiationBase``, used by :ref:`albedo` and :ref:`earthRadiationModel`, was also corrected, even though that case is not reached for patches on the planet surface. +- GitHub issue 1612: The :ref:`spiceInterface` module computed the GPS time of ``spiceTimeOutMsg`` as the + elapsed TDB time since the GPS epoch instead of atomic time. ``GPSSeconds`` was in error by a periodic term of up + to 1.7 ms, and ``GPSWeek`` and ``GPSRollovers`` could be one week early at a week boundary. ``JulianDateCurrent`` + was read from a SPICE Julian date string, which has at most seven decimals, and was therefore quantized to + 1e-7 day. + - GitHub issue 1609: :ref:`keplerianOrbit` returned incorrect eccentric and mean anomalies, lost the sign of the flight-path angle, and returned zero rates after construction or stale rates after ``set_a()``. These outputs are now consistent with the current elliptic orbit. diff --git a/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst b/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst new file mode 100644 index 00000000000..8074a318ed7 --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst @@ -0,0 +1 @@ +- Fixed :ref:`spiceInterface` GPS week and seconds of ``spiceTimeOutMsg``, which were computed from TDB instead of atomic time, and its UTC Julian date, which was quantized to 1e-7 day. diff --git a/src/architecture/msgPayloadDefC/SpiceTimeMsgPayload.h b/src/architecture/msgPayloadDefC/SpiceTimeMsgPayload.h index 90f065a0581..7ec41caf0ef 100755 --- a/src/architecture/msgPayloadDefC/SpiceTimeMsgPayload.h +++ b/src/architecture/msgPayloadDefC/SpiceTimeMsgPayload.h @@ -25,9 +25,9 @@ //! The SPICE time output structure outputs time information to the rest of the system typedef struct { - double J2000Current; //!< s Current J2000 elapsed time - double JulianDateCurrent; //!< d Current JulianDate - double GPSSeconds; //!< s Current GPS seconds + double J2000Current; //!< s Current ephemeris time (TDB) elapsed since J2000 + double JulianDateCurrent; //!< d Current UTC Julian date + double GPSSeconds; //!< s Current GPS seconds of week uint16_t GPSWeek; //!< -- Current GPS week value uint64_t GPSRollovers; //!< -- Count on the number of GPS rollovers }SpiceTimeMsgPayload; diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceBufferOwnership.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceBufferOwnership.py index ba71bbb0234..88c871ee58e 100644 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceBufferOwnership.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceBufferOwnership.py @@ -24,7 +24,7 @@ def test_time_conversion_after_repeated_reset(picture, expected): elapsed_seconds = 1.0 # [s] initial_julian_date = 2451545.0 # [day] seconds_per_day = 86400.0 # [s/day] - date_tolerance = 5.0e-8 # [day] Half a unit in the final SPICE Julian-date decimal place. + date_tolerance = 1.0e-9 # [day] Double precision of the Julian date, with margin. for _ in range(3): model.Reset(0) model.UpdateState(macros.sec2nano(elapsed_seconds)) @@ -44,7 +44,7 @@ def test_time_output_rejects_insufficient_capacity(picture_length): model.getCurrentTimeString() -@pytest.mark.parametrize("field", ["spiceBuffer", "charBufferSize"]) +@pytest.mark.parametrize("field", ["charBufferSize"]) def test_scratch_storage_is_private(field): """Python callers cannot replace the buffer or change its capacity.""" model = spiceInterface.SpiceInterface(auto_configure_kernels=False) diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py new file mode 100644 index 00000000000..cc5540299bc --- /dev/null +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py @@ -0,0 +1,77 @@ + +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""Check the GPS time and UTC Julian date of the SPICE time output message against their definitions.""" + +import datetime + +import pytest + +from Basilisk import __path__ +from Basilisk.simulation import spiceInterface +from Basilisk.utilities import SimulationBaseClass, macros + +bsk_path = __path__[0] + +GPS_EPOCH = datetime.datetime(1980, 1, 6) # GPS epoch, 1980 January 6, 00:00:00 UTC +UNIX_EPOCH = datetime.datetime(1970, 1, 1) # Unix epoch, 1970 January 1, 00:00:00 UTC +UNIX_EPOCH_JULIAN_DATE = 2440587.5 # [d] Julian date of the Unix epoch +SECONDS_PER_DAY = 86400.0 # [s] +SECONDS_PER_WEEK = 604800.0 # [s] +WEEKS_PER_ROLLOVER = 1024 # GPS weeks between rollovers + + +@pytest.mark.parametrize("epoch, leap_seconds", [ + (datetime.datetime(1999, 8, 21, 23, 59, 47), 13), # GPS week 1024 starts at this UTC time + (datetime.datetime(2015, 7, 1, 0, 0, 0), 17), # just after the leap second of 2015 June 30 + (datetime.datetime(2024, 4, 4, 0, 0, 0), 18), # TDB - TT near its maximum of about +1.7 ms + (datetime.datetime(2026, 3, 20, 12, 0, 0, 123000), 18), # fractional UTC seconds +]) +def test_gps_time_and_julian_date(epoch, leap_seconds): + """The GPS week, GPS seconds of week and UTC Julian date of ``spiceTimeOutMsg`` equal their definitions. + + GPS time is the UTC time elapsed since the GPS epoch plus the leap seconds introduced since then, and the + Julian date is the UTC Julian date. The epochs include a GPS week rollover, a date just after a leap second, + and a date near the maximum of the periodic difference between TDB and atomic time. + """ + sim = SimulationBaseClass.SimBaseClass() + process = sim.CreateNewProcess("process") + process.addTask(sim.CreateNewTask("task", macros.sec2nano(1.0))) + + spice = spiceInterface.SpiceInterface() + spice.SPICEDataPath = bsk_path + "/supportData/EphemerisData/" + spice.UTCCalInit = epoch.strftime("%Y %B %d, %H:%M:%S.%f") + " (UTC)" + sim.AddModelToTask("task", spice) + + time_log = spice.spiceTimeOutMsg.recorder() + sim.AddModelToTask("task", time_log) + + sim.InitializeSimulation() + sim.ConfigureStopTime(0) + sim.ExecuteSimulation() + + gps_time = (epoch - GPS_EPOCH).total_seconds() + leap_seconds # [s] + julian_date = UNIX_EPOCH_JULIAN_DATE + (epoch - UNIX_EPOCH).total_seconds()/SECONDS_PER_DAY # [d] + + gps_week = int(time_log.GPSWeek[-1]) + WEEKS_PER_ROLLOVER*int(time_log.GPSRollovers[-1]) + assert gps_week == int(gps_time//SECONDS_PER_WEEK) + assert time_log.GPSSeconds[-1] == pytest.approx(gps_time % SECONDS_PER_WEEK, rel=0.0, abs=1.0e-6) # [s] + assert time_log.JulianDateCurrent[-1] == pytest.approx(julian_date, rel=0.0, abs=1.0e-9) # [d] + + +if __name__ == "__main__": + test_gps_time_and_julian_date(datetime.datetime(1999, 8, 21, 23, 59, 47), 13) diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_unitSpice.py b/src/simulation/environment/spiceInterface/_UnitTest/test_unitSpice.py index c93ca70149e..ba7fafb3498 100755 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_unitSpice.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_unitSpice.py @@ -250,6 +250,11 @@ def unitSpice(testPlottingFixture, show_plots, DateSpice, DatePlot, MarsTruthPos GPSEndTime = GPSTimeSeconds + 17 + 60.0 - 68.184 # 17 GPS skip seconds passed that date else: GPSEndTime = GPSTimeSeconds + 16 + 60.0 - 67.184 # 16 GPS skip seconds before + # GPS time is atomic time, so remove the periodic TDB - TT term of the SPICE leap seconds kernel + ETEnd = (date - datetime.datetime(2000, 1, 1, 12)).total_seconds() + 60.0 # [s] TDB past J2000 + meanAnomaly = 6.239996 + 1.99096871E-7*ETEnd # [rad] Earth-Moon barycenter mean anomaly + eccentricAnomaly = meanAnomaly + 1.671E-2*numpy.sin(meanAnomaly) # [rad] + GPSEndTime -= 1.657E-3*numpy.sin(eccentricAnomaly) # [s] GPSWeek = int(GPSEndTime / (86400 * 7)) GPSSecondAssumed = GPSEndTime - GPSWeek * 86400 * 7 diff --git a/src/simulation/environment/spiceInterface/spiceInterface.cpp b/src/simulation/environment/spiceInterface/spiceInterface.cpp index c0e7c5ab253..4fa6a0a345e 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.cpp +++ b/src/simulation/environment/spiceInterface/spiceInterface.cpp @@ -34,6 +34,9 @@ #include "spiceInterface.h" namespace { + constexpr double julianDateJ2000 = 2451545.0; //!< [d] Julian date of the J2000 epoch + constexpr double secondsPerDay = 86400.0; //!< [s] Seconds in a Julian day + /** * RAII guard for SPICE error mode. * @@ -716,8 +719,6 @@ void SpiceInterface::Reset(uint64_t CurrenSimNanos) */ void SpiceInterface::initTimeData() { - double EpochDelteET; - /* set epoch information. If provided, then the epoch message information should be used. */ if (this->epochInMsg.isLinked()) { // Read in the epoch message and set the internal time structure @@ -737,26 +738,23 @@ void SpiceInterface::initTimeData() str2et_c(this->GPSEpochTime.c_str(), &this->JDGPSEpoch); //! - Get the time value associate with the requested UTC date str2et_c(this->UTCCalInit.c_str(), &this->J2000ETInit); - //! - Take the JD epoch and get the elapsed time for it - deltet_c(this->JDGPSEpoch, "ET", &EpochDelteET); - } -/*! This method computes the GPS time data for the current elapsed time. It uses - the total elapsed times at both the GPS epoch time and the current time to - compute the GPS time (week, seconds, rollovers) +/*! This method computes the GPS time data for the current elapsed time. GPS time + is atomic time (TAI - 19 s), so the elapsed time since the GPS epoch is the + difference of the TAI times at the current time and at the GPS epoch. This + elapsed time gives the GPS time (week, seconds, rollovers). */ void SpiceInterface::computeGPSData() { - double JDDifference; - - //! - The difference between the epochs in julian date terms is the total - JDDifference = this->J2000Current - this->JDGPSEpoch; + //! - Take the elapsed atomic time since the GPS epoch + double gpsElapsed = unitim_c(this->J2000Current, "TDB", "TAI") + - unitim_c(this->JDGPSEpoch, "TDB", "TAI"); // [s] //! - Scale the elapsed by a week's worth of seconds to get week - this->GPSWeek = (uint16_t) (JDDifference/(7*86400)); + this->GPSWeek = (uint16_t) (gpsElapsed/(7*86400)); //! - Subtract out the GPS week scaled up to seconds to get time in week - this->GPSSeconds = JDDifference - this->GPSWeek*7*86400; + this->GPSSeconds = gpsElapsed - this->GPSWeek*7*86400; //! - Maximum GPS week is 1024 so get rollovers and subtract out those weeks this->GPSRollovers = this->GPSWeek/1024; @@ -821,10 +819,10 @@ void SpiceInterface::UpdateState(uint64_t CurrentSimNanos) //! - Increment the J2000 elapsed time based on init value and Current sim this->J2000Current = this->J2000ETInit + static_cast(CurrentSimNanos) * NANO2SEC; - //! - Compute the current Julian Date string and cast it over to the double - et2utc_c(this->J2000Current, "J", 14, this->charBufferSize - 1, this->spiceBuffer.data()); - std::string localString = this->spiceBuffer.data() + 3; - this->julianDateCurrent = std::stod(localString); + //! - Compute the current UTC Julian date from the UTC seconds past J2000 + double deltaET; // [s] ET - UTC + deltet_c(this->J2000Current, "ET", &deltaET); + this->julianDateCurrent = julianDateJ2000 + (this->J2000Current - deltaET) / secondsPerDay; // [d] //! Get GPS and Planet data and then write the message outputs. Reconstruction is allowed only //! on the planet pass (the raw sim time drives the knot grid; the spacecraft pass stays exact). this->computeGPSData(); diff --git a/src/simulation/environment/spiceInterface/spiceInterface.h b/src/simulation/environment/spiceInterface/spiceInterface.h index 8b889f80b8b..2089fc394de 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.h +++ b/src/simulation/environment/spiceInterface/spiceInterface.h @@ -230,8 +230,8 @@ class SpiceInterface: public SysModel { bool timeDataInit; //!< -- Flag indicating whether time has been init double J2000ETInit; //!< s Seconds elapsed since J2000 at init double J2000Current; //!< s Current J2000 elapsed time - double julianDateCurrent; //!< s Current JulianDate - double GPSSeconds; //!< s Current GPS seconds + double julianDateCurrent; //!< d Current UTC Julian date + double GPSSeconds; //!< s Current GPS seconds of week uint16_t GPSWeek; //!< -- Current GPS week value uint64_t GPSRollovers; //!< -- Count on the number of GPS rollovers @@ -239,10 +239,9 @@ class SpiceInterface: public SysModel { private: static constexpr int charBufferSize = 512; //!< Capacity of SPICE scratch buffers, including the null terminator. - std::array spiceBuffer{}; //!< Scratch storage for Julian date conversion. std::string GPSEpochTime; //!< -- String for the GPS epoch - double JDGPSEpoch; //!< s Epoch for GPS time. Saved for efficiency + double JDGPSEpoch; //!< s Ephemeris time of the GPS epoch, seconds past J2000. Saved for efficiency std::vector planetData; std::vector scData; diff --git a/src/simulation/environment/spiceInterface/spiceInterface.rst b/src/simulation/environment/spiceInterface/spiceInterface.rst index d70d68f7e19..dafa6da8c4e 100644 --- a/src/simulation/environment/spiceInterface/spiceInterface.rst +++ b/src/simulation/environment/spiceInterface/spiceInterface.rst @@ -53,3 +53,12 @@ messages in three different formats. only prescribe the spacecraft attitude motion. - ``transRefStateOutMsgs[]``: these are the translational reference message :ref:`TransRefMsgPayload`. These are useful to only prescribe the translational motion and leave the attitude motion free. + +The time output message ``spiceTimeOutMsg`` of type :ref:`SpiceTimeMsgPayload` provides the current time in +the following time scales: + +- ``J2000Current``: the ephemeris time (TDB) in seconds past J2000. +- ``JulianDateCurrent``: the UTC Julian date. +- ``GPSWeek``, ``GPSSeconds`` and ``GPSRollovers``: the GPS week modulo 1024, the seconds of week and the number of + 1024 week rollovers. GPS time is atomic time (TAI - 19 s) elapsed since the GPS epoch 1980 January 6, + 00:00:00 UTC, and therefore includes the leap seconds introduced since that epoch. From b64e40c9f40ce8cc7ceeeb6e1d47aa5795396fe7 Mon Sep 17 00:00:00 2001 From: Carlo Cena Date: Fri, 16 Oct 2026 13:43:25 +0200 Subject: [PATCH 2/5] [#1612] GPS seconds of week in floating point to avoid int overflow --- .../spiceInterface/_UnitTest/test_spiceTimeOutput.py | 4 +++- .../environment/spiceInterface/spiceInterface.cpp | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py index cc5540299bc..4326dd12be0 100644 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py @@ -40,13 +40,15 @@ (datetime.datetime(2015, 7, 1, 0, 0, 0), 17), # just after the leap second of 2015 June 30 (datetime.datetime(2024, 4, 4, 0, 0, 0), 18), # TDB - TT near its maximum of about +1.7 ms (datetime.datetime(2026, 3, 20, 12, 0, 0, 123000), 18), # fractional UTC seconds + (datetime.datetime(2048, 6, 1, 0, 0, 0), 18), # past GPS week 3551, where week*604800 s overflows a 32-bit int ]) def test_gps_time_and_julian_date(epoch, leap_seconds): """The GPS week, GPS seconds of week and UTC Julian date of ``spiceTimeOutMsg`` equal their definitions. GPS time is the UTC time elapsed since the GPS epoch plus the leap seconds introduced since then, and the Julian date is the UTC Julian date. The epochs include a GPS week rollover, a date just after a leap second, - and a date near the maximum of the periodic difference between TDB and atomic time. + a date near the maximum of the periodic difference between TDB and atomic time, and a date after GPS week 3551, + where the GPS week expressed in seconds no longer fits in a signed 32-bit integer. """ sim = SimulationBaseClass.SimBaseClass() process = sim.CreateNewProcess("process") diff --git a/src/simulation/environment/spiceInterface/spiceInterface.cpp b/src/simulation/environment/spiceInterface/spiceInterface.cpp index 4fa6a0a345e..40a6ac68e04 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.cpp +++ b/src/simulation/environment/spiceInterface/spiceInterface.cpp @@ -751,10 +751,11 @@ void SpiceInterface::computeGPSData() //! - Take the elapsed atomic time since the GPS epoch double gpsElapsed = unitim_c(this->J2000Current, "TDB", "TAI") - unitim_c(this->JDGPSEpoch, "TDB", "TAI"); // [s] + const double secondsPerWeek = 604800.0; // [s] //! - Scale the elapsed by a week's worth of seconds to get week - this->GPSWeek = (uint16_t) (gpsElapsed/(7*86400)); - //! - Subtract out the GPS week scaled up to seconds to get time in week - this->GPSSeconds = gpsElapsed - this->GPSWeek*7*86400; + this->GPSWeek = (uint16_t) (gpsElapsed/secondsPerWeek); + //! - Subtract out the GPS week scaled up to seconds to get time in week, in floating point to avoid integer overflow + this->GPSSeconds = gpsElapsed - static_cast(this->GPSWeek)*secondsPerWeek; // [s] //! - Maximum GPS week is 1024 so get rollovers and subtract out those weeks this->GPSRollovers = this->GPSWeek/1024; From 562cb124fbef34cf328b6b1330edd71b1ba9187d Mon Sep 17 00:00:00 2001 From: Carlo Cena Date: Fri, 16 Oct 2026 13:48:07 +0200 Subject: [PATCH 3/5] [#1612] Cache GPS epoch TAI and zero GPS time before the GPS epoch --- .../_UnitTest/test_spiceTimeOutput.py | 49 +++++++++++++------ .../spiceInterface/spiceInterface.cpp | 30 +++++++----- .../spiceInterface/spiceInterface.h | 2 +- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py index 4326dd12be0..57da606fca2 100644 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py @@ -1,4 +1,3 @@ - # ISC License # # Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado Boulder @@ -35,20 +34,11 @@ WEEKS_PER_ROLLOVER = 1024 # GPS weeks between rollovers -@pytest.mark.parametrize("epoch, leap_seconds", [ - (datetime.datetime(1999, 8, 21, 23, 59, 47), 13), # GPS week 1024 starts at this UTC time - (datetime.datetime(2015, 7, 1, 0, 0, 0), 17), # just after the leap second of 2015 June 30 - (datetime.datetime(2024, 4, 4, 0, 0, 0), 18), # TDB - TT near its maximum of about +1.7 ms - (datetime.datetime(2026, 3, 20, 12, 0, 0, 123000), 18), # fractional UTC seconds - (datetime.datetime(2048, 6, 1, 0, 0, 0), 18), # past GPS week 3551, where week*604800 s overflows a 32-bit int -]) -def test_gps_time_and_julian_date(epoch, leap_seconds): - """The GPS week, GPS seconds of week and UTC Julian date of ``spiceTimeOutMsg`` equal their definitions. +def run_spice_time_output(epoch): + """Run ``spiceInterface`` for a single update at ``epoch`` and return the ``spiceTimeOutMsg`` recorder. - GPS time is the UTC time elapsed since the GPS epoch plus the leap seconds introduced since then, and the - Julian date is the UTC Julian date. The epochs include a GPS week rollover, a date just after a leap second, - a date near the maximum of the periodic difference between TDB and atomic time, and a date after GPS week 3551, - where the GPS week expressed in seconds no longer fits in a signed 32-bit integer. + :param epoch: UTC time at which the simulation starts + :return: recorder of ``spiceTimeOutMsg`` """ sim = SimulationBaseClass.SimBaseClass() process = sim.CreateNewProcess("process") @@ -65,8 +55,28 @@ def test_gps_time_and_julian_date(epoch, leap_seconds): sim.InitializeSimulation() sim.ConfigureStopTime(0) sim.ExecuteSimulation() + return time_log + + +# gps_utc_offset is GPS - UTC, the leap seconds introduced since the GPS epoch, i.e. TAI - UTC - 19 s [s] +@pytest.mark.parametrize("epoch, gps_utc_offset", [ + (datetime.datetime(1999, 8, 21, 23, 59, 47), 13), # GPS week 1024 starts at this UTC time + (datetime.datetime(2015, 7, 1, 0, 0, 0), 17), # just after the leap second of 2015 June 30 + (datetime.datetime(2024, 4, 4, 0, 0, 0), 18), # TDB - TT near its maximum of about +1.7 ms + (datetime.datetime(2026, 3, 20, 12, 0, 0, 123000), 18), # fractional UTC seconds + (datetime.datetime(2048, 6, 1, 0, 0, 0), 18), # past GPS week 3551, where week*604800 s overflows a 32-bit int +]) +def test_gps_time_and_julian_date(epoch, gps_utc_offset): + """The GPS week, GPS seconds of week and UTC Julian date of ``spiceTimeOutMsg`` equal their definitions. - gps_time = (epoch - GPS_EPOCH).total_seconds() + leap_seconds # [s] + GPS time is the UTC time elapsed since the GPS epoch plus the leap seconds introduced since then, and the + Julian date is the UTC Julian date. The epochs include a GPS week rollover, a date just after a leap second, + a date near the maximum of the periodic difference between TDB and atomic time, and a date after GPS week 3551, + where the GPS week expressed in seconds no longer fits in a signed 32-bit integer. + """ + time_log = run_spice_time_output(epoch) + + gps_time = (epoch - GPS_EPOCH).total_seconds() + gps_utc_offset # [s] julian_date = UNIX_EPOCH_JULIAN_DATE + (epoch - UNIX_EPOCH).total_seconds()/SECONDS_PER_DAY # [d] gps_week = int(time_log.GPSWeek[-1]) + WEEKS_PER_ROLLOVER*int(time_log.GPSRollovers[-1]) @@ -75,5 +85,14 @@ def test_gps_time_and_julian_date(epoch, leap_seconds): assert time_log.JulianDateCurrent[-1] == pytest.approx(julian_date, rel=0.0, abs=1.0e-9) # [d] +def test_gps_time_before_gps_epoch(): + """Before the GPS epoch, where GPS time is undefined, the GPS week, seconds and rollovers are held at zero.""" + time_log = run_spice_time_output(datetime.datetime(1979, 6, 1, 0, 0, 0)) + + assert int(time_log.GPSWeek[-1]) == 0 + assert int(time_log.GPSRollovers[-1]) == 0 + assert time_log.GPSSeconds[-1] == 0.0 # [s] + + if __name__ == "__main__": test_gps_time_and_julian_date(datetime.datetime(1999, 8, 21, 23, 59, 47), 13) diff --git a/src/simulation/environment/spiceInterface/spiceInterface.cpp b/src/simulation/environment/spiceInterface/spiceInterface.cpp index 40a6ac68e04..e5e380f3269 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.cpp +++ b/src/simulation/environment/spiceInterface/spiceInterface.cpp @@ -587,7 +587,7 @@ SpiceInterface::SpiceInterface() GPSWeek = 0; GPSRollovers = 0; timeDataInit = false; - JDGPSEpoch = 0.0; + TAIGPSEpoch = 0.0; GPSEpochTime = "1980 January 6, 00:00:00.0"; referenceBase = "j2000"; @@ -734,10 +734,16 @@ void SpiceInterface::initTimeData() } } - //! -Get the time value associated with the GPS epoch - str2et_c(this->GPSEpochTime.c_str(), &this->JDGPSEpoch); + //! - Get the TAI of the GPS epoch, which is fixed, so the conversion is done once here + double etGPSEpoch; + str2et_c(this->GPSEpochTime.c_str(), &etGPSEpoch); + this->TAIGPSEpoch = unitim_c(etGPSEpoch, "TDB", "TAI"); // [s] //! - Get the time value associate with the requested UTC date str2et_c(this->UTCCalInit.c_str(), &this->J2000ETInit); + if (this->J2000ETInit < etGPSEpoch) { + bskLogger.bskLog(BSK_WARNING, "The initial time is before the GPS epoch, where GPS time is undefined. " + "The GPS time outputs are zero until the GPS epoch."); + } } /*! This method computes the GPS time data for the current elapsed time. GPS time @@ -748,18 +754,18 @@ void SpiceInterface::initTimeData() */ void SpiceInterface::computeGPSData() { - //! - Take the elapsed atomic time since the GPS epoch - double gpsElapsed = unitim_c(this->J2000Current, "TDB", "TAI") - - unitim_c(this->JDGPSEpoch, "TDB", "TAI"); // [s] + //! - Take the elapsed atomic time since the GPS epoch, held at zero before the epoch where GPS time is undefined + double gpsElapsed = std::max(unitim_c(this->J2000Current, "TDB", "TAI") - this->TAIGPSEpoch, 0.0); // [s] const double secondsPerWeek = 604800.0; // [s] - //! - Scale the elapsed by a week's worth of seconds to get week - this->GPSWeek = (uint16_t) (gpsElapsed/secondsPerWeek); - //! - Subtract out the GPS week scaled up to seconds to get time in week, in floating point to avoid integer overflow - this->GPSSeconds = gpsElapsed - static_cast(this->GPSWeek)*secondsPerWeek; // [s] + const uint64_t weeksPerRollover = 1024; + //! - Scale the elapsed by a week's worth of seconds to get the total number of weeks + uint64_t totalWeeks = static_cast(gpsElapsed/secondsPerWeek); + //! - Subtract out the weeks scaled up to seconds to get time in week, in floating point to avoid integer overflow + this->GPSSeconds = gpsElapsed - static_cast(totalWeeks)*secondsPerWeek; // [s] //! - Maximum GPS week is 1024 so get rollovers and subtract out those weeks - this->GPSRollovers = this->GPSWeek/1024; - this->GPSWeek = (uint16_t)(this->GPSWeek-this->GPSRollovers*1024); + this->GPSRollovers = totalWeeks/weeksPerRollover; + this->GPSWeek = static_cast(totalWeeks % weeksPerRollover); } /*! This method takes the values computed in the model and outputs them. diff --git a/src/simulation/environment/spiceInterface/spiceInterface.h b/src/simulation/environment/spiceInterface/spiceInterface.h index 2089fc394de..af6492b50df 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.h +++ b/src/simulation/environment/spiceInterface/spiceInterface.h @@ -241,7 +241,7 @@ class SpiceInterface: public SysModel { static constexpr int charBufferSize = 512; //!< Capacity of SPICE scratch buffers, including the null terminator. std::string GPSEpochTime; //!< -- String for the GPS epoch - double JDGPSEpoch; //!< s Ephemeris time of the GPS epoch, seconds past J2000. Saved for efficiency + double TAIGPSEpoch; //!< s TAI of the GPS epoch, seconds past J2000. Saved for efficiency std::vector planetData; std::vector scData; From 7ae98e7a23163800bdb2a970e0192ffe51c69f75 Mon Sep 17 00:00:00 2001 From: Carlo Cena Date: Fri, 16 Oct 2026 13:56:10 +0200 Subject: [PATCH 4/5] [#1612] Document GPS epoch handling and overflow fix, reword GPS warning --- docs/source/Support/bskKnownIssues.rst | 7 ++++--- .../bskReleaseNotesSnippets/1612-spice-gps-time.rst | 1 + .../spiceInterface/_UnitTest/test_spiceTimeOutput.py | 2 +- .../environment/spiceInterface/spiceInterface.cpp | 8 ++++---- .../environment/spiceInterface/spiceInterface.rst | 4 +++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/source/Support/bskKnownIssues.rst b/docs/source/Support/bskKnownIssues.rst index 8ed1e419782..3b640a0a053 100644 --- a/docs/source/Support/bskKnownIssues.rst +++ b/docs/source/Support/bskKnownIssues.rst @@ -14,10 +14,11 @@ Version |release| that case is not reached for patches on the planet surface. - GitHub issue 1612: The :ref:`spiceInterface` module computed the GPS time of ``spiceTimeOutMsg`` as the - elapsed TDB time since the GPS epoch instead of atomic time. ``GPSSeconds`` was in error by a periodic term of up - to 1.7 ms, and ``GPSWeek`` and ``GPSRollovers`` could be one week early at a week boundary. ``JulianDateCurrent`` + elapsed TDB time since the GPS epoch instead of atomic time. ``GPSSeconds`` was in error by a periodic term of + a few milliseconds, and ``GPSWeek`` and ``GPSRollovers`` could be one week early at a week boundary. ``JulianDateCurrent`` was read from a SPICE Julian date string, which has at most seven decimals, and was therefore quantized to - 1e-7 day. + 1e-7 day. ``GPSSeconds`` overflowed a 32-bit integer after GPS week 3550, and a simulation start + before the GPS epoch produced invalid GPS outputs. - GitHub issue 1609: :ref:`keplerianOrbit` returned incorrect eccentric and mean anomalies, lost the sign of the flight-path angle, and returned zero rates after construction or stale diff --git a/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst b/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst index 8074a318ed7..1ec5b01aca6 100644 --- a/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst +++ b/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst @@ -1 +1,2 @@ - Fixed :ref:`spiceInterface` GPS week and seconds of ``spiceTimeOutMsg``, which were computed from TDB instead of atomic time, and its UTC Julian date, which was quantized to 1e-7 day. +- Fixed :ref:`spiceInterface` ``GPSSeconds`` integer overflow after GPS week 3550, and held the GPS time outputs at zero with a warning when the simulation starts before the GPS epoch. diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py index 57da606fca2..0b45e9bb4fc 100644 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py @@ -86,7 +86,7 @@ def test_gps_time_and_julian_date(epoch, gps_utc_offset): def test_gps_time_before_gps_epoch(): - """Before the GPS epoch, where GPS time is undefined, the GPS week, seconds and rollovers are held at zero.""" + """Before the GPS epoch, the GPS week, seconds and rollovers are held at zero.""" time_log = run_spice_time_output(datetime.datetime(1979, 6, 1, 0, 0, 0)) assert int(time_log.GPSWeek[-1]) == 0 diff --git a/src/simulation/environment/spiceInterface/spiceInterface.cpp b/src/simulation/environment/spiceInterface/spiceInterface.cpp index e5e380f3269..cd5dd2ee2d8 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.cpp +++ b/src/simulation/environment/spiceInterface/spiceInterface.cpp @@ -741,8 +741,8 @@ void SpiceInterface::initTimeData() //! - Get the time value associate with the requested UTC date str2et_c(this->UTCCalInit.c_str(), &this->J2000ETInit); if (this->J2000ETInit < etGPSEpoch) { - bskLogger.bskLog(BSK_WARNING, "The initial time is before the GPS epoch, where GPS time is undefined. " - "The GPS time outputs are zero until the GPS epoch."); + bskLogger.bskLog(BSK_WARNING, "The initial time is before the GPS epoch. " + "The GPS time outputs are held at zero until the GPS epoch."); } } @@ -754,7 +754,7 @@ void SpiceInterface::initTimeData() */ void SpiceInterface::computeGPSData() { - //! - Take the elapsed atomic time since the GPS epoch, held at zero before the epoch where GPS time is undefined + //! - Take the elapsed atomic time since the GPS epoch, held at zero before the epoch double gpsElapsed = std::max(unitim_c(this->J2000Current, "TDB", "TAI") - this->TAIGPSEpoch, 0.0); // [s] const double secondsPerWeek = 604800.0; // [s] const uint64_t weeksPerRollover = 1024; @@ -763,7 +763,7 @@ void SpiceInterface::computeGPSData() //! - Subtract out the weeks scaled up to seconds to get time in week, in floating point to avoid integer overflow this->GPSSeconds = gpsElapsed - static_cast(totalWeeks)*secondsPerWeek; // [s] - //! - Maximum GPS week is 1024 so get rollovers and subtract out those weeks + //! - Maximum GPS week is 1024 so get the rollovers and the week modulo 1024 this->GPSRollovers = totalWeeks/weeksPerRollover; this->GPSWeek = static_cast(totalWeeks % weeksPerRollover); } diff --git a/src/simulation/environment/spiceInterface/spiceInterface.rst b/src/simulation/environment/spiceInterface/spiceInterface.rst index dafa6da8c4e..1ef7ac4c215 100644 --- a/src/simulation/environment/spiceInterface/spiceInterface.rst +++ b/src/simulation/environment/spiceInterface/spiceInterface.rst @@ -61,4 +61,6 @@ the following time scales: - ``JulianDateCurrent``: the UTC Julian date. - ``GPSWeek``, ``GPSSeconds`` and ``GPSRollovers``: the GPS week modulo 1024, the seconds of week and the number of 1024 week rollovers. GPS time is atomic time (TAI - 19 s) elapsed since the GPS epoch 1980 January 6, - 00:00:00 UTC, and therefore includes the leap seconds introduced since that epoch. + 00:00:00 UTC, and therefore includes the leap seconds introduced since that epoch. Negative GPS time is not + modeled: if the simulation starts before the GPS epoch, the module emits a warning at reset and the GPS outputs + are held at zero until the GPS epoch is reached. From 251806e28966e76583a82b709e052958700ad344 Mon Sep 17 00:00:00 2001 From: Hanspeter Schaub Date: Fri, 16 Oct 2026 18:08:13 -0600 Subject: [PATCH 5/5] [#1612] Preserve SPICE Julian dates through leap seconds --- docs/source/Support/bskKnownIssues.rst | 3 +- .../1612-spice-gps-time.rst | 2 +- .../_UnitTest/test_spiceTimeOutput.py | 59 +++++++++++++++++++ .../spiceInterface/spiceInterface.cpp | 11 +++- .../spiceInterface/spiceInterface.rst | 4 +- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/source/Support/bskKnownIssues.rst b/docs/source/Support/bskKnownIssues.rst index 3b640a0a053..98f8f18f98d 100644 --- a/docs/source/Support/bskKnownIssues.rst +++ b/docs/source/Support/bskKnownIssues.rst @@ -18,7 +18,8 @@ Version |release| a few milliseconds, and ``GPSWeek`` and ``GPSRollovers`` could be one week early at a week boundary. ``JulianDateCurrent`` was read from a SPICE Julian date string, which has at most seven decimals, and was therefore quantized to 1e-7 day. ``GPSSeconds`` overflowed a 32-bit integer after GPS week 3550, and a simulation start - before the GPS epoch produced invalid GPS outputs. + before the GPS epoch produced invalid GPS outputs. The corrected UTC Julian date preserves the SPICE + convention of holding at the following midnight during an inserted leap second, avoiding backward jumps. - GitHub issue 1609: :ref:`keplerianOrbit` returned incorrect eccentric and mean anomalies, lost the sign of the flight-path angle, and returned zero rates after construction or stale diff --git a/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst b/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst index 1ec5b01aca6..b842f50d76c 100644 --- a/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst +++ b/docs/source/Support/bskReleaseNotesSnippets/1612-spice-gps-time.rst @@ -1,2 +1,2 @@ -- Fixed :ref:`spiceInterface` GPS week and seconds of ``spiceTimeOutMsg``, which were computed from TDB instead of atomic time, and its UTC Julian date, which was quantized to 1e-7 day. +- Fixed :ref:`spiceInterface` GPS week and seconds of ``spiceTimeOutMsg``, which were computed from TDB instead of atomic time, and its UTC Julian date, which was quantized to 1e-7 day, while preserving the SPICE convention of holding the Julian date at midnight during an inserted leap second. - Fixed :ref:`spiceInterface` ``GPSSeconds`` integer overflow after GPS week 3550, and held the GPS time outputs at zero with a warning when the simulation starts before the GPS epoch. diff --git a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py index 0b45e9bb4fc..2d5ffdd493a 100644 --- a/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py +++ b/src/simulation/environment/spiceInterface/_UnitTest/test_spiceTimeOutput.py @@ -94,5 +94,64 @@ def test_gps_time_before_gps_epoch(): assert time_log.GPSSeconds[-1] == 0.0 # [s] +@pytest.mark.parametrize("epoch, gps_utc_offset, has_leap_second", [ + (datetime.datetime(1998, 12, 31, 23, 59, 59), 12, True), # before J2000 + (datetime.datetime(2015, 6, 30, 23, 59, 59), 16, True), # June leap second + (datetime.datetime(2016, 12, 31, 23, 59, 59), 17, True), # December leap second + (datetime.datetime(2026, 3, 20, 23, 59, 59), 18, False), # ordinary midnight +]) +def test_time_output_across_midnight(epoch, gps_utc_offset, has_leap_second): + """Keep SPICE JDUTC fixed during an inserted second while GPS time advances. + + Sample before, within and after the leap second, and also cross an ordinary + midnight to ensure fractional UTC seconds are preserved on normal days. + """ + spice = spiceInterface.SpiceInterface() + spice.UTCCalInit = epoch.strftime("%Y-%m-%d %H:%M:%S") + " UTC" + spice.Reset(0) + + midnight = epoch + datetime.timedelta(seconds=1) + midnight_julian_date = UNIX_EPOCH_JULIAN_DATE + (midnight - UNIX_EPOCH).total_seconds()/SECONDS_PER_DAY # [d] + initial_gps_time = (epoch - GPS_EPOCH).total_seconds() + gps_utc_offset # [s] + sample_times = (0.0, 0.5, 0.999, 1.0, 1.001, 1.5, 1.999, 2.0, 2.001, 2.5, 3.0) # [s] + previous_julian_date = None + for elapsed in sample_times: + spice.UpdateState(macros.sec2nano(elapsed)) + time_output = spice.spiceTimeOutMsg.read() + if has_leap_second: + seconds_from_midnight = min(elapsed - 1.0, 0.0) + max(elapsed - 2.0, 0.0) # [s] + else: + seconds_from_midnight = elapsed - 1.0 # [s] + expected_julian_date = midnight_julian_date + seconds_from_midnight/SECONDS_PER_DAY # [d] + expected_gps_time = initial_gps_time + elapsed # [s] + gps_week = int(time_output.GPSWeek) + WEEKS_PER_ROLLOVER*int(time_output.GPSRollovers) + + assert time_output.JulianDateCurrent == pytest.approx(expected_julian_date, rel=0.0, abs=1.0e-9) # [d] + if has_leap_second and 1.0 <= elapsed <= 2.0: # [s] Inserted second through the following midnight. + assert time_output.JulianDateCurrent == midnight_julian_date + if previous_julian_date is not None: + assert time_output.JulianDateCurrent >= previous_julian_date + previous_julian_date = time_output.JulianDateCurrent + assert gps_week == int(expected_gps_time//SECONDS_PER_WEEK) + assert time_output.GPSSeconds == pytest.approx(expected_gps_time % SECONDS_PER_WEEK, + rel=0.0, abs=1.0e-6) # [s] + + +def test_gps_time_crosses_gps_epoch(): + """Release the zero GPS outputs when a simulation crosses the GPS epoch.""" + spice = spiceInterface.SpiceInterface() + spice.UTCCalInit = "1980 JAN 05 23:59:59 UTC" + spice.Reset(0) + + sample_times = (0.0, 0.5, 1.0, 1.5, 2.0) # [s] + for elapsed in sample_times: + spice.UpdateState(macros.sec2nano(elapsed)) + time_output = spice.spiceTimeOutMsg.read() + expected_seconds = max(elapsed - 1.0, 0.0) # [s] + assert time_output.GPSWeek == 0 + assert time_output.GPSRollovers == 0 + assert time_output.GPSSeconds == pytest.approx(expected_seconds, rel=0.0, abs=1.0e-6) # [s] + + if __name__ == "__main__": test_gps_time_and_julian_date(datetime.datetime(1999, 8, 21, 23, 59, 47), 13) diff --git a/src/simulation/environment/spiceInterface/spiceInterface.cpp b/src/simulation/environment/spiceInterface/spiceInterface.cpp index cd5dd2ee2d8..b4c36657229 100755 --- a/src/simulation/environment/spiceInterface/spiceInterface.cpp +++ b/src/simulation/environment/spiceInterface/spiceInterface.cpp @@ -829,7 +829,16 @@ void SpiceInterface::UpdateState(uint64_t CurrentSimNanos) //! - Compute the current UTC Julian date from the UTC seconds past J2000 double deltaET; // [s] ET - UTC deltet_c(this->J2000Current, "ET", &deltaET); - this->julianDateCurrent = julianDateJ2000 + (this->J2000Current - deltaET) / secondsPerDay; // [d] + double utcSeconds = this->J2000Current - deltaET; // [s] UTC seconds past J2000 + double utcDeltaET; // [s] ET - UTC when interpreting utcSeconds as a UTC epoch + deltet_c(utcSeconds, "UTC", &utcDeltaET); + const double leapSecondThreshold = 0.5; // [s] Distinguish a leap from roundoff in the periodic ET - TAI term. + if (utcDeltaET - deltaET > leapSecondThreshold) { + // During an inserted second, utcSeconds falls in the first second of the next day, + // whose UTC offset is larger. Preserve SPICE JDUTC by holding at that midnight. + utcSeconds = std::floor(utcSeconds); + } + this->julianDateCurrent = julianDateJ2000 + utcSeconds / secondsPerDay; // [d] //! Get GPS and Planet data and then write the message outputs. Reconstruction is allowed only //! on the planet pass (the raw sim time drives the knot grid; the spacecraft pass stays exact). this->computeGPSData(); diff --git a/src/simulation/environment/spiceInterface/spiceInterface.rst b/src/simulation/environment/spiceInterface/spiceInterface.rst index 1ef7ac4c215..589ca1722dd 100644 --- a/src/simulation/environment/spiceInterface/spiceInterface.rst +++ b/src/simulation/environment/spiceInterface/spiceInterface.rst @@ -58,7 +58,9 @@ The time output message ``spiceTimeOutMsg`` of type :ref:`SpiceTimeMsgPayload` p the following time scales: - ``J2000Current``: the ephemeris time (TDB) in seconds past J2000. -- ``JulianDateCurrent``: the UTC Julian date. +- ``JulianDateCurrent``: the UTC Julian date. Following the SPICE JDUTC convention, this value is held + at the following midnight during an inserted leap second. Use ``J2000Current`` or the GPS fields to + distinguish instants within that second. - ``GPSWeek``, ``GPSSeconds`` and ``GPSRollovers``: the GPS week modulo 1024, the seconds of week and the number of 1024 week rollovers. GPS time is atomic time (TAI - 19 s) elapsed since the GPS epoch 1980 January 6, 00:00:00 UTC, and therefore includes the leap seconds introduced since that epoch. Negative GPS time is not