Source code for matviz.datetime_converter

# filename: datetime_converter.py
from __future__ import annotations

import datetime as _dt
from typing import Optional

import numpy as np
import pandas as pd

try:
    from zoneinfo import ZoneInfo  # Python 3.9+
except Exception:
    ZoneInfo = None

_UTC = _dt.timezone.utc
_EPOCH = _dt.datetime(1970, 1, 1, tzinfo=_UTC)

[docs] class DateCodec: """ Single-value, reversible timestamp <-> integer nanoseconds codec. - ``to_number(ts)`` -> int (ns since UNIX epoch, UTC) - ``from_number(ns)`` -> same type as input and same tz-awareness """ def __init__(self): # minimal state for round-trip self._kind: Optional[str] = None # 'py' | 'np' | 'pd' self._aware: bool = False # original tz-aware? self._tz_key: Optional[str] = None # zone name (e.g. 'America/New_York'), if any self._tzinfo: Optional[_dt.tzinfo] = None # fallback tzinfo object (for Python datetime)
[docs] def reset(self) -> None: self._kind = None self._aware = False self._tz_key = None self._tzinfo = None
# ---------- encode ----------
[docs] def to_number(self, ts) -> int: """Accepts: datetime, np.datetime64, or pd.Timestamp. Returns int nanoseconds since epoch (UTC).""" if isinstance(ts, _dt.datetime): self._kind = "py" self._aware = ts.tzinfo is not None and ts.tzinfo.utcoffset(ts) is not None if self._aware: self._tzinfo = ts.tzinfo self._tz_key = getattr(ts.tzinfo, "key", None) or getattr(ts.tzinfo, "zone", None) dt_utc = ts.astimezone(_UTC) else: self._tzinfo = None self._tz_key = None # treat naive as UTC wall time dt_utc = ts.replace(tzinfo=_UTC) return self._dt_to_ns(dt_utc) if isinstance(ts, pd.Timestamp): self._kind = "pd" t = pd.Timestamp(ts) self._aware = t.tz is not None if self._aware: self._tz_key = getattr(t.tz, "key", None) or getattr(t.tz, "zone", None) ns = int(t.tz_convert("UTC").value) # UTC ns else: self._tz_key = None ns = int(t.tz_localize("UTC").value) # treat naive as UTC wall time return ns if isinstance(ts, np.datetime64): self._kind = "np" self._aware = False self._tz_key = None self._tzinfo = None return int(np.datetime64(ts, "ns").astype("int64")) raise TypeError(f"Unsupported type: {type(ts)}")
# ---------- decode ----------
[docs] def from_number(self, ns: int): """Return the same type (datetime | np.datetime64 | pd.Timestamp) as the original input.""" if self._kind is None: raise RuntimeError("No state saved. Call to_number(...) first.") if self._kind == "py": dt_utc = self._ns_to_dt(ns) # aware UTC if not self._aware: return dt_utc.replace(tzinfo=None) # prefer named zone if available if self._tz_key and ZoneInfo is not None: try: return dt_utc.astimezone(ZoneInfo(self._tz_key)) except Exception: pass # otherwise use original tzinfo if we have it if self._tzinfo is not None: try: return dt_utc.astimezone(self._tzinfo) except Exception: return dt_utc return dt_utc if self._kind == "pd": ts = pd.to_datetime(ns, unit="ns", utc=True) if self._aware: if self._tz_key: try: return ts.tz_convert(self._tz_key) except Exception: return ts return ts # keep UTC-aware if we can't resolve the zone else: return ts.tz_convert(None) # return naive if self._kind == "np": return np.datetime64(int(ns), "ns") raise AssertionError("unreachable")
# ---------- helpers ---------- @staticmethod def _dt_to_ns(dt_utc: _dt.datetime) -> int: """Python datetime (tz-aware UTC) -> integer nanoseconds since epoch.""" # Avoid float timestamps for reversibility. Python datetime has microsecond resolution. delta = dt_utc - _EPOCH # delta only has microseconds; represent as ns with trailing zeros return (delta.days * 86_400 + delta.seconds) * 1_000_000_000 + delta.microseconds * 1_000 @staticmethod def _ns_to_dt(ns: int) -> _dt.datetime: """Integer nanoseconds since epoch -> Python datetime in UTC (aware).""" seconds, rem_ns = divmod(int(ns), 1_000_000_000) micro, _ = divmod(rem_ns, 1_000) # Python datetime supports microseconds (not full ns) return _EPOCH + _dt.timedelta(seconds=seconds, microseconds=micro)
"""usage: codec = DateCodec() num_series = date_series.apply(codec.to_number) date_series_again = num_series.apply(codec.from_number) """