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:
- OemWriter —
START_TIME,STOP_TIME, per-stateEPOCH - OpmWriter — state vector
EPOCH,REF_FRAME_EPOCH,MAN_EPOCH_IGNITION - OmmWriter — mean-element
EPOCH - OcmWriter —
EPOCH_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 - AbstractMessageWriter —
CREATION_DATE
Notes
- The precision-controlled
DateTimeComponents.toString(int minuteDuration, int fractionDigits)produces clean output for the sameAbsoluteDate, so the building blocks for a fix are already present in the API. So does thetoString()method as demonstrated in the example above.
Thanks for looking into this!