ODM writers (OEM/OPM/OMM/OCM) print sub-second fields with 1-ULP drift via `TimeComponents.getSecond()`

Hi all,

I believe I have found a potential bug, which has been causing some issues for us when generating CCSDS files such as OEMs and then parsing them from file. Below you will find a detailed description of the bug, how to reproduce it and where I believe the issue lies.

If this is indeed a bug, I hope you’ll be able to find a fix for the next release.

The (potential) bug

In Orekit 13.1.4, every ODM writer that emits a date field through generator.writeEntry(name, timeConverter, date, ...) surfaces a 1-ULP drift on the rendered calendar string whenever the sub-second value is not an exact binary fraction. The drift is at the 16th decimal (cosmetic-looking) but enough to break strict AbsoluteDate.equals() round-trips between the file and the source. We hit this on OemWriter and have reproduced it on OpmWriter and OcmWriter as well; the same code path is used by OmmWriter and AbstractMessageWriter.

Example: feed AbsoluteDate("2026-05-21T23:04:00.934", utc) to the OPM writer, and you get

<CREATION_DATE>2026-05-21T23:04:00.9339999999999999</CREATION_DATE>
<EPOCH>2026-05-21T23:04:00.9339999999999999</EPOCH>

If you try and then parse this file back to an Opm object, the absoluteDate value will return 2026-05-21T23:04:00.9339999999999999 instead of the original 2026-05-21T23:04:00.934 value. Dyadic values like 0.625 s survive unchanged, which to me is the giveaway that this is a binary-representation issue at write time.

Reproducing the bug

Bellow is a small end-to-end python demonstration of the lossy step (using orekit-jpype):

import os
from jdk4py import JAVA_HOME
os.environ["JAVA_HOME"] = str(JAVA_HOME)

import orekit_jpype as orekit
orekit.initVM()

from orekit_jpype.pyhelpers import setup_orekit_curdir
setup_orekit_curdir('orekit-data')

from org.orekit.time import AbsoluteDate, TimeScalesFactory

utc = TimeScalesFactory.getUTC()
d = AbsoluteDate("2026-05-21T23:04:00.934", utc)

print(d.toString(utc))
# 2026-05-21T23:04:00.934                <- faithful (uses the TimeOffset)

print(d.getComponents(utc).getTime().getSecond())
# 0.9339999999999999                     <- lossy (TimeOffset -> double)

Feeding d to any ODM writer surfaces the .9339999999999999 form on disk, both in metadata fields (START_TIME/STOP_TIME/EPOCH_TZERO/…) and in per-state data blocks. The same drifted form also appears for CREATION_DATE in the header.

Where I think the issue lies

The write-time chain for every writeEntry(..., timeConverter, date, ...) call:

AbstractGenerator.writeEntry(name, timeConverter, date, ...)         [AbstractGenerator.java:172-185]

  -> AbstractGenerator.dateToCalendarString(timeConverter, date)      [AbstractGenerator.java:268-272]

    -> timeConverter.components(date).getTime().getSecond()           <- double

      -> TimeComponents.getSecond()                                    [TimeComponents.java:500-502]

        -> second.toDouble()

          -> TimeOffset.toDouble():                                    [TimeOffset.java:730-747]

              return closeSeconds + ((double) signedAttoSeconds) / ATTOS_IN_SECOND;

    -> AccurateFormatter.format(... double seconds ...)                [AccurateFormatter.java:78-85]

      -> RyuDouble.doubleToString(seconds, ...)

I believe the lossy step is TimeOffset.toDouble(): the integer attoseconds divided by ATTOS_IN_SECOND (= 1e18) yields the nearest IEEE-754 double to the true sub-second value. For 0.934 s that double is the lower neighbor (0.93399999999999994315...), and Ryū’s shortest round-trip decimal of it is the string "0.9339999999999999". By contrast, AbsoluteDate.toString(TimeScale) formats from the underlying TimeOffset directly and prints the clean "0.934". So the precision is preserved in memory; only the writeEntry(..., timeConverter, ...) path loses it.

This affects every ODM writer:

  • OemWriterSTART_TIME, STOP_TIME, per-state EPOCH
  • OpmWriter — state vector EPOCH, REF_FRAME_EPOCH, MAN_EPOCH_IGNITION
  • OmmWriter — mean-element EPOCH
  • OcmWriterEPOCH_TZERO, PREVIOUS_NEXT_MESSAGE_EPOCH, START_TIME, STOP_TIME, NEXT_LEAP_EPOCH, TRAJ_FRAME_EPOCH, USEABLE_START_TIME, USEABLE_STOP_TIME, per-state trajectory epochs
  • AbstractMessageWriterCREATION_DATE

Notes

  • The precision-controlled DateTimeComponents.toString(int minuteDuration, int fractionDigits) produces clean output for the same AbsoluteDate, so the building blocks for a fix are already present in the API. So does the toString() method as demonstrated in the example above.

Thanks for looking into this!

Hello @willem.suter and welcome to the Orekit forum !

First of all thank you for the detailed post, this makes investigation much easier.

From what i see, this is indeed a bug due to how AbsoluteDate is formatted into a date String. We currently do not use the accuracy provided by TimeOffset in AbstractGenerator (as you pointed out):

    /** {@inheritDoc} */
    @Override
    public String dateToCalendarString(final TimeConverter converter, final AbsoluteDate date) {
        final DateTimeComponents dt = converter.components(date);
        return dateToString(dt.getDate().getYear(), dt.getDate().getMonth(), dt.getDate().getDay(),
                            dt.getTime().getHour(), dt.getTime().getMinute(), dt.getTime().getSecond());
    }

but hopefully, this can easily be fixed.

I believe we can add this to the next 13.1.6 release !

Issue opened: Fix ULP drift during CCSDS file writing (#1962) · Issues · Orekit / Orekit · GitLab

Cheers,
Vincent

Hey @willem.suter,

This message to let you know that It has been fixed in latest patch :ok_hand:.

Cheers
Vincent