Skip to content

Commit 009c761

Browse files
authored
Merge pull request #86 from bbc/philipn-float-timestamps
Add to_float timestamp methods and further support to negative timestamps
2 parents b6e3d24 + 572c455 commit 009c761

3 files changed

Lines changed: 160 additions & 38 deletions

File tree

mediatimestamp/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
print("ips-tai-nsec {}".format(ts.to_tai_sec_nsec()))
2727
print("ips-tai-frac {}".format(ts.to_tai_sec_frac()))
2828
print("utc {}".format(ts.to_iso8601_utc()))
29-
print("utc-secs {}".format(ts.to_utc()[0]))
29+
print("utc-secs {}".format(ts.to_unix()[0]))
3030
print("smpte time label {}".format(ts.to_smpte_timelabel(50, 1)))
3131
sys.exit(0)
3232

mediatimestamp/immutable/timestamp.py

Lines changed: 77 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,12 @@ def __mediatimerange__(self) -> "TimeRange":
148148
@classmethod
149149
def get_time(cls) -> "Timestamp":
150150
unix_time = time.time()
151-
return cls.from_unix(int(unix_time), int(unix_time*cls.MAX_NANOSEC) - int(unix_time)*cls.MAX_NANOSEC)
151+
abs_unix_time = abs(unix_time)
152+
unix_sec = int(abs_unix_time)
153+
unix_ns = int(abs_unix_time*cls.MAX_NANOSEC) - int(abs_unix_time)*cls.MAX_NANOSEC
154+
unix_sign = 1 if unix_time >= 0 else -1
155+
156+
return cls.from_unix(unix_sec, unix_ns, unix_sign=unix_sign)
152157

153158
@classmethod
154159
@deprecated(version="4.0.0",
@@ -238,10 +243,18 @@ def from_float(cls, toff_float: float) -> "Timestamp":
238243
def from_datetime(cls, dt: datetime) -> "Timestamp":
239244
minTs = datetime.fromtimestamp(0, tz.gettz('UTC'))
240245
utcdt = dt.astimezone(tz.gettz('UTC'))
241-
seconds = int((utcdt - minTs).total_seconds())
246+
seconds = abs(int((utcdt - minTs).total_seconds()))
242247
nanoseconds = utcdt.microsecond * 1000
248+
if utcdt < minTs:
249+
sign = -1
250+
if nanoseconds > 0:
251+
# The microseconds was for a positive date-time. In a negative
252+
# unix time it needs to be flipped.
253+
nanoseconds = cls.MAX_NANOSEC - nanoseconds
254+
else:
255+
sign = 1
243256

244-
return cls.from_unix(seconds, nanoseconds, False)
257+
return cls.from_unix(unix_sec=seconds, unix_ns=nanoseconds, unix_sign=sign, is_leap=False)
245258

246259
@classmethod
247260
def from_iso8601_utc(cls, iso8601utc: str) -> "Timestamp":
@@ -250,7 +263,18 @@ def from_iso8601_utc(cls, iso8601utc: str) -> "Timestamp":
250263
year, month, day, hour, minute, second, ns = _parse_iso8601(iso8601utc[:-1])
251264
gmtuple = (year, month, day, hour, minute, second - (second == 60))
252265
secs_since_epoch = calendar.timegm(gmtuple)
253-
return cls.from_unix(secs_since_epoch, ns, (second == 60))
266+
if secs_since_epoch < 0:
267+
sign = -1
268+
secs_since_epoch = abs(secs_since_epoch)
269+
if ns > 0:
270+
# The ns parsed from the timestamp was for a positive ISO 8601 date-time. In a negative
271+
# unix time it needs to be flipped.
272+
ns = cls.MAX_NANOSEC - ns
273+
secs_since_epoch -= 1
274+
else:
275+
sign = 1
276+
277+
return cls.from_unix(unix_sec=secs_since_epoch, unix_ns=ns, unix_sign=sign, is_leap=(second == 60))
254278

255279
@classmethod
256280
def from_smpte_timelabel(cls, timelabel: str) -> "Timestamp":
@@ -319,19 +343,16 @@ def from_count(cls, count: int, rate_num: RationalTypes, rate_den: RationalTypes
319343
return cls(ns=ns, sign=sign)
320344

321345
@classmethod
322-
def from_unix(cls, unix_sec: int, unix_ns: int, is_leap: bool = False) -> "Timestamp":
346+
def from_unix(cls, unix_sec: int, unix_ns: int, unix_sign: int = 1, is_leap: bool = False) -> "Timestamp":
323347
leap_sec = 0
324-
for tbl_sec, tbl_tai_sec_minus_1 in UTC_LEAP:
325-
if unix_sec >= tbl_sec:
326-
leap_sec = (tbl_tai_sec_minus_1 + 1) - tbl_sec
327-
break
328-
return cls(sec=unix_sec+leap_sec+is_leap, ns=unix_ns)
329-
330-
@classmethod
331-
def from_utc(cls, utc_sec: int, utc_ns: int, is_leap: bool = False) -> "Timestamp":
332-
""" Wrapper of from_unix for back-compatibility.
333-
"""
334-
return cls.from_unix(utc_sec, utc_ns, is_leap)
348+
if unix_sign >= 0:
349+
for tbl_sec, tbl_tai_sec_minus_1 in UTC_LEAP:
350+
if unix_sec + is_leap >= tbl_sec:
351+
leap_sec = (tbl_tai_sec_minus_1 + 1) - tbl_sec
352+
break
353+
else:
354+
is_leap = False
355+
return cls(sec=unix_sec+leap_sec, ns=unix_ns, sign=unix_sign)
335356

336357
def is_null(self) -> bool:
337358
return self._value == 0
@@ -405,47 +426,67 @@ def to_tai_sec_nsec(self) -> str:
405426
def to_tai_sec_frac(self, fixed_size: bool = False) -> str:
406427
return self.to_sec_frac(fixed_size=fixed_size)
407428

429+
def to_float(self) -> float:
430+
""" Convert to a floating point number of seconds
431+
"""
432+
return self._value / Timestamp.MAX_NANOSEC
433+
408434
def to_datetime(self) -> datetime:
409-
sec, nsec, leap = self.to_unix()
435+
sec, nsec, sign, leap = self.to_unix()
410436
microsecond = int(round(nsec/1000))
411437
if microsecond > 999999:
412438
sec += 1
413439
microsecond = 0
414-
dt = datetime.fromtimestamp(sec, tz.gettz('UTC'))
440+
if sign < 0 and microsecond > 0:
441+
# The microseconds is for a negative unix time. In a positive date-time
442+
# it needs to be flipped.
443+
microsecond = 1000000 - microsecond
444+
sec += 1
445+
dt = datetime.fromtimestamp(sign * sec, tz.gettz('UTC'))
415446
dt = dt.replace(microsecond=microsecond)
416447

417448
return dt
418449

419-
def to_unix(self) -> Tuple[int, int, bool]:
450+
def to_unix(self) -> Tuple[int, int, int, bool]:
420451
""" Convert to unix seconds.
421452
Returns a tuple of (seconds, nanoseconds, is_leap), where `is_leap` is
422453
`True` when the input time corresponds exactly to a UTC leap second.
423454
Note that this deliberately returns a tuple, to try and avoid confusion.
424455
"""
425-
leap_sec = 0
426-
is_leap = False
427-
for unix_sec, tai_sec_minus_1 in UTC_LEAP:
428-
if self.sec >= tai_sec_minus_1:
429-
leap_sec = (tai_sec_minus_1 + 1) - unix_sec
430-
is_leap = self.sec == tai_sec_minus_1
431-
break
432-
433-
return (self.sec - leap_sec, self.ns, is_leap)
434-
435-
def to_utc(self) -> Tuple[int, int, bool]:
436-
""" Wrapper of to_unix for back-compatibility.
456+
if self._value < 0:
457+
return (self.sec, self.ns, self.sign, False)
458+
else:
459+
leap_sec = 0
460+
is_leap = False
461+
for unix_sec, tai_sec_minus_1 in UTC_LEAP:
462+
if self.sec >= tai_sec_minus_1:
463+
leap_sec = (tai_sec_minus_1 + 1) - unix_sec
464+
is_leap = self.sec == tai_sec_minus_1
465+
break
466+
467+
return (self.sec - leap_sec, self.ns, self.sign, is_leap)
468+
469+
def to_unix_float(self) -> float:
470+
""" Convert to unix seconds since the epoch as a floating point number
437471
"""
438-
return self.to_unix()
472+
(sec, ns, sign, _) = self.to_unix()
473+
return sign * (sec + ns / Timestamp.MAX_NANOSEC)
439474

440475
def to_iso8601_utc(self) -> str:
441476
""" Get printed representation in ISO8601 format (UTC)
442477
YYYY-MM-DDThh:mm:ss.s
443478
where `s` is fractional seconds at nanosecond precision (always 9-chars wide)
444479
"""
445-
unix_s, unix_ns, is_leap = self.to_unix()
446-
utc_bd = time.gmtime(unix_s)
447-
frac_sec = self._get_fractional_seconds(fixed_size=True)
480+
unix_s, unix_ns, unix_sign, is_leap = self.to_unix()
481+
if unix_sign < 0 and unix_ns > 0:
482+
# The nanoseconds is for a negative unix time. In a positive ISO 8601 date-time
483+
# it needs to be flipped.
484+
unix_ns = Timestamp.MAX_NANOSEC - unix_ns
485+
unix_s += 1
486+
utc_bd = time.gmtime(unix_sign * unix_s)
487+
frac_sec = Timestamp(ns=unix_ns)._get_fractional_seconds(fixed_size=True)
448488
leap_sec = int(is_leap)
489+
449490
return '%04d-%02d-%02dT%02d:%02d:%02d.%sZ' % (utc_bd.tm_year,
450491
utc_bd.tm_mon,
451492
utc_bd.tm_mday,
@@ -467,7 +508,7 @@ def to_smpte_timelabel(self,
467508
count_on_or_after_second = Timestamp(tai_seconds, 0).to_count(rate, rounding=self.ROUND_UP)
468509
count_within_second = count - count_on_or_after_second
469510

470-
unix_sec, unix_ns, is_leap = normalised_ts.to_unix()
511+
unix_sec, unix_ns, unix_sign, is_leap = normalised_ts.to_unix()
471512
leap_sec = int(is_leap)
472513

473514
if utc_offset is None:

tests/test_timestamp.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,22 @@ def test_from_float(self):
277277
self.assertEqual(r, case[1],
278278
msg="Timestamp.from_float{!r} == {!r}, expected {!r}".format(case[0], r, case[1]))
279279

280+
def test_to_float(self):
281+
"""This tests that timestamps can be created from a float."""
282+
cases = [
283+
((float(1.0)), Timestamp(1, 0)),
284+
((float(1_000_000_000)), Timestamp(1_000_000_000, 0)),
285+
((float(2.76)), Timestamp(2, 760_000_000)),
286+
((float(-3.14)), Timestamp(3, 140_000_000, -1)),
287+
((float(0.02)), Timestamp(0, 20_000_000))
288+
]
289+
290+
for case in cases:
291+
with self.subTest(case=case):
292+
r = case[1].to_float()
293+
self.assertEqual(r, case[1],
294+
msg="{!r},to_float() == {!r}, expected {!r}".format(case[1], r, case[0]))
295+
280296
def test_set_value(self):
281297
"""This tests that timestamps cannot have their value set."""
282298
tests_ts = [
@@ -730,6 +746,12 @@ def test_convert_iso_utc(self):
730746
"""This tests that conversion to and from ISO date format UTC time works as expected."""
731747

732748
tests = [
749+
(Timestamp(62135596800, 0, -1), "0001-01-01T00:00:00.000000000Z"),
750+
(Timestamp(1, 0, -1), "1969-12-31T23:59:59.000000000Z"),
751+
(Timestamp(0, 999999999, -1), "1969-12-31T23:59:59.000000001Z"),
752+
(Timestamp(0, 1, -1), "1969-12-31T23:59:59.999999999Z"),
753+
(Timestamp(0, 0, 1), "1970-01-01T00:00:00.000000000Z"),
754+
733755
(Timestamp(1424177663, 102003), "2015-02-17T12:53:48.000102003Z"),
734756

735757
# the leap second is 23:59:60
@@ -751,7 +773,8 @@ def test_convert_iso_utc(self):
751773
(Timestamp(1341100835, 100000000), "2012-07-01T00:00:00.100000000Z"),
752774
(Timestamp(1341100835, 999999999), "2012-07-01T00:00:00.999999999Z"),
753775

754-
(Timestamp(283996818, 0), "1979-01-01T00:00:00.000000000Z") # 1979
776+
(Timestamp(283996818, 0), "1979-01-01T00:00:00.000000000Z"), # 1979
777+
(Timestamp(253402300836, 999999999), "9999-12-31T23:59:59.999999999Z")
755778
]
756779

757780
for t in tests:
@@ -827,9 +850,16 @@ def test_from_datetime(self):
827850
"""Conversion from python's datetime object."""
828851

829852
tests = [
853+
(datetime(1, 1, 1, 0, 0, 0, 0, tz.gettz('UTC')), Timestamp(62135596800, 0, -1)),
854+
(datetime(1969, 12, 31, 23, 59, 59, 0, tz.gettz('UTC')), Timestamp(1, 0, -1)),
855+
(datetime(1969, 12, 31, 23, 59, 59, 1, tz.gettz('UTC')), Timestamp(0, 999999000, -1)),
856+
(datetime(1969, 12, 31, 23, 59, 59, 999999, tz.gettz('UTC')), Timestamp(0, 1000, -1)),
830857
(datetime(1970, 1, 1, 0, 0, 0, 0, tz.gettz('UTC')), Timestamp(0, 0)),
831858
(datetime(1983, 3, 29, 15, 45, 0, 0, tz.gettz('UTC')), Timestamp(417800721, 0)),
832859
(datetime(2017, 12, 5, 16, 33, 12, 196, tz.gettz('UTC')), Timestamp(1512491629, 196000)),
860+
(datetime(2514, 1, 1, 0, 0, 0, 0, tz.gettz('UTC')), Timestamp(17166988837, 0, 1)),
861+
# Stopping around here because high datetime values have a floating point error.
862+
# See https://stackoverflow.com/a/75582241.
833863
]
834864

835865
for t in tests:
@@ -839,10 +869,18 @@ def test_to_datetime(self):
839869
"""Conversion to python's datetime object."""
840870

841871
tests = [
872+
(datetime(1, 1, 1, 0, 0, 0, 0, tz.gettz('UTC')), Timestamp(62135596800, 0, -1)),
873+
(datetime(1969, 12, 31, 23, 59, 59, 0, tz.gettz('UTC')), Timestamp(1, 0, -1)),
874+
(datetime(1969, 12, 31, 23, 59, 59, 1, tz.gettz('UTC')), Timestamp(0, 999999000, -1)),
875+
(datetime(1969, 12, 31, 23, 59, 59, 999999, tz.gettz('UTC')), Timestamp(0, 1000, -1)),
842876
(datetime(1970, 1, 1, 0, 0, 0, 0, tz.gettz('UTC')), Timestamp(0, 0)),
877+
(datetime(1970, 1, 1, 0, 0, 0, 1, tz.gettz('UTC')), Timestamp(0, 1000)),
843878
(datetime(1983, 3, 29, 15, 45, 0, 0, tz.gettz('UTC')), Timestamp(417800721, 0)),
844879
(datetime(2017, 12, 5, 16, 33, 12, 196, tz.gettz('UTC')), Timestamp(1512491629, 196000)),
845880
(datetime(2017, 12, 5, 16, 33, 13, 0, tz.gettz('UTC')), Timestamp(1512491629, 999999999)),
881+
(datetime(2514, 1, 1, 0, 0, 0, 0, tz.gettz('UTC')), Timestamp(17166988837, 0, 1)),
882+
# Stopping around here because high datetime values have a floating point error.
883+
# See https://stackoverflow.com/a/75582241
846884
]
847885

848886
for t in tests:
@@ -879,3 +917,46 @@ def test_get_leap_seconds(self):
879917

880918
for t in tests:
881919
self.assertEqual(t[0].get_leap_seconds(), t[1])
920+
921+
def test_from_unix(self):
922+
tests = [
923+
((Timestamp.MAX_SECONDS - 1, Timestamp.MAX_NANOSEC - 1, -1, False), # 0 leap seconds
924+
Timestamp(Timestamp.MAX_SECONDS - 1, Timestamp.MAX_NANOSEC - 1, -1)), # 0 leap seconds
925+
((1000, 0, -1, False), Timestamp(1000, 0, -1)), # 0 leap seconds
926+
((63071999, 999999999, 1, False), Timestamp(63071999, 999999999)), # 0 leap seconds
927+
((63071999, 0, 1, True), Timestamp(63072009, 0)), # 10 leap seconds at leap
928+
((63072000, 0, 1, False), Timestamp(63072010, 0)), # 10 leap seconds
929+
((63072008, 999999999, 1, False), Timestamp(63072018, 999999999)), # 10 leap seconds
930+
((1512491592, 0, 1, False), Timestamp(1512491629, 0)), # 37 leap seconds
931+
((Timestamp.MAX_SECONDS - 1 - 37, Timestamp.MAX_NANOSEC - 1, 1, False),
932+
Timestamp(Timestamp.MAX_SECONDS - 1, Timestamp.MAX_NANOSEC - 1)), # 37 leap seconds
933+
]
934+
935+
for t in tests:
936+
self.assertEqual(Timestamp.from_unix(*t[0]), t[1])
937+
938+
def test_to_unix(self):
939+
tests = [
940+
(Timestamp(Timestamp.MAX_SECONDS - 1, Timestamp.MAX_NANOSEC - 1, -1), # 0 leap seconds
941+
(Timestamp.MAX_SECONDS - 1, Timestamp.MAX_NANOSEC - 1, -1, False)), # 0 leap seconds
942+
(Timestamp(1000, 0, -1), (1000, 0, -1, False)), # 0 leap seconds
943+
(Timestamp(63072008, 999999999), (63072008, 999999999, 1, False)), # 0 leap seconds
944+
(Timestamp(63072009, 0), (63071999, 0, 1, True)), # 10 leap seconds at leap
945+
(Timestamp(63072010, 0), (63072000, 0, 1, False)), # 10 leap seconds
946+
(Timestamp(1512491629, 0), (1512491592, 0, 1, False)), # 37 leap seconds
947+
(Timestamp(Timestamp.MAX_SECONDS - 1, Timestamp.MAX_NANOSEC - 1),
948+
(Timestamp.MAX_SECONDS - 1 - 37, Timestamp.MAX_NANOSEC - 1, 1, False)), # 37 leap seconds
949+
]
950+
951+
for t in tests:
952+
self.assertEqual(t[0].to_unix(), t[1])
953+
954+
def test_to_unix_float(self):
955+
tests = [
956+
(Timestamp(63072008, 999999999), 63072008 + 999999999 / 1000000000),
957+
(Timestamp(63072009, 0), 63071999),
958+
(Timestamp(1000, 0, -1), -1000)
959+
]
960+
961+
for t in tests:
962+
self.assertEqual(t[0].to_unix_float(), t[1])

0 commit comments

Comments
 (0)