Skip to content

Parse fractional seconds exactly in timestamp() (fix off-by-one microseconds) - #178

Open
gaoflow wants to merge 1 commit into
Knio:masterfrom
gaoflow:fix-timestamp-fractional-seconds
Open

Parse fractional seconds exactly in timestamp() (fix off-by-one microseconds)#178
gaoflow wants to merge 1 commit into
Knio:masterfrom
gaoflow:fix-timestamp-fractional-seconds

Conversation

@gaoflow

@gaoflow gaoflow commented Jun 30, 2026

Copy link
Copy Markdown

The bug

nmea_utils.timestamp() converts the fractional seconds of a sentence time through a binary float, which truncates and yields microseconds that are off by one for ~1.15% of inputs:

>>> from pynmea2.nmea_utils import timestamp
>>> timestamp('120000.00397').microsecond
3969          # expected 3970  (.00397 s == 3.97 ms == exactly 3970 us)
>>> timestamp('120000.00399').microsecond
3989          # expected 3990

timestamp() is the library's central time parser, so this reaches every sentence that carries a time (GGA/RMC/GLL/ZDA/…) via parse():

>>> import pynmea2
>>> pynmea2.parse('$GPGGA,181032.00397,3926.276,N,07739.361,W,0,00,,,M,,M,,*5F',
...               checksums='my_data_is_corrupt').timestamp.microsecond
3969          # expected 3970

A sweep of all 100000 five-digit fractions finds 1153 systematically wrong, always exactly 1 µs low.

Why the expected value is unambiguous

microsecond is an integer field, and a fractional second with ≤6 digits is exactly representable at microsecond resolution — .00397 s is exactly 3970 µs. The existing test_timestamp already asserts exact values (.123456→123456, .1234→123400) and test_nor asserts .3341→334100; it simply never picked a float-unsafe fraction, so it missed this.

Root cause

pynmea2/nmea_utils.py:

ms = ms_s and int(float(ms_s) * 1000000) or 0

Binary floats can't represent most decimal fractions exactly; float('.00397') * 1000000 is 3969.9999999…, and int() truncates downward → off by one. (A naive round() "fix" is also wrong: round(0.9999995 * 1e6) == 1000000, which datetime.time(...) rejects with microsecond must be in 0..999999.)

The fix

Parse the fractional digits as an integer, right-padded/truncated to 6-digit microsecond resolution — exact, and inherently overflow-safe:

if ms_s.startswith('.'):
    ms = int((ms_s[1:] + '000000')[:6])
else:
    ms = 0

Tests

test_timestamp_fractional_seconds_exact: the known-bad values, an exhaustive sweep asserting every 5-digit fraction i gives i*10 µs, the >6-digit truncation and the .9999999999999 no-overflow boundary, the no-fraction case, and the public parse() entry point. The new test fails on master (assert 3969 == 3970) and passes with the fix; the full suite stays green (106 passed), and all pre-existing timestamp assertions (incl. the 7-digit truncation case and test_nor) are preserved.

timestamp() computed microseconds as int(float(frac) * 1000000), but a
binary float can't represent most decimal fractions exactly, so the
product lands just under the true integer and int() truncates down: e.g.
'.00397' s gave 3969 us instead of 3970, wrong for 1153 of every 100000
five-digit fractions. Parse the fractional digits as an integer instead
(right-padded/truncated to microsecond resolution), which is exact and
cannot overflow datetime's 0..999999 microsecond range.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant