Source code for time_convention

"""Single source of truth for timezone/format conversions in the monitoring pipeline.

All methods are vectorised: they accept and return arrays / Series / xarray objects.
No per-scalar wrappers are created.
"""
import datetime

import dateutil.rrule
import numpy as np
import pandas as pd
import pytz
import xarray as xr


[docs] class TimeConvention: """Centralised timezone policy and conversion helpers. Owns the project timezone and the canonical on-disk storage format. The storage format uses two encodings for the same instant: * ``time`` coordinate — tz-naive UTC ``datetime64[ns]`` (xarray index). * ``start_time`` / ``file_time`` variables — float64 POSIX seconds. Attributes ---------- tz : pytz.BaseTzInfo The project timezone, e.g. ``pytz.timezone('Europe/Berlin')``. """ def __init__(self, tz: str = "Europe/Berlin"): """Initialise the timezone policy. Parameters ---------- tz : str, optional IANA timezone name. Defaults to ``'Europe/Berlin'`` (CET/CEST), the timezone of the monitored structure this pipeline was built for. """ self.tz = pytz.timezone(tz)
[docs] def to_storage_coord(self, aware) -> np.datetime64: """Convert a timezone-aware datetime to a tz-naive UTC ``datetime64``. This is the canonical conversion used before writing a time coordinate to the NetCDF database. It is equivalent to:: pd.Timestamp(aware).tz_convert('UTC').tz_localize(None).to_datetime64() Parameters ---------- aware : datetime.datetime or pd.Timestamp A timezone-aware datetime. ``pytz``-aware and ``dateutil``-aware objects are both accepted. Returns ------- numpy.datetime64 Tz-naive UTC ``datetime64`` suitable for use as an xarray ``time`` coordinate. """ return pd.Timestamp(aware).tz_convert("UTC").tz_localize(None).to_datetime64()
[docs] def to_local(self, stored) -> pd.Timestamp: """Convert a stored tz-naive coordinate back to a Berlin-aware Timestamp. Used in ``create_stats`` and ``create_modal_results`` to recover a Berlin-aware ``pd.Timestamp`` from the UTC-naive value that is stored in the database. .. note:: This applies the "double-localisation" pattern: the stored UTC-naive value is *re-localised* as Berlin (not converted from UTC). Times that fall inside the spring-forward DST gap (02:00–02:59 on the last Sunday in March in ``Europe/Berlin``) map to ``NaT`` rather than raising, because ``nonexistent='NaT'`` is passed to ``tz_localize``. Parameters ---------- stored : numpy.datetime64, pd.Timestamp, or str A tz-naive datetime value as stored in the database ``time`` coordinate. Returns ------- pd.Timestamp Berlin-aware timestamp, or ``NaT`` if *stored* falls inside a DST spring-forward gap. """ return pd.Timestamp(stored).tz_localize(str(self.tz), nonexistent="NaT")
[docs] def posix_to_datetime64(self, posix_s): """Convert float POSIX seconds to ``datetime64[s]`` (vectorised). Convenience wrapper around ``.astype('datetime64[s]')`` that accepts both plain NumPy arrays and xarray ``DataArray`` objects. Parameters ---------- posix_s : float, array-like, or xarray.DataArray POSIX timestamp(s) in seconds since the Unix epoch (1970-01-01 UTC). Returns ------- numpy.ndarray or xarray.DataArray ``datetime64[s]`` representation of the same instant(s). """ if isinstance(posix_s, xr.DataArray): return posix_s.astype("datetime64[s]") return np.asarray(posix_s).astype("datetime64[s]")
[docs] def to_posix(self, dt) -> float: """Convert a timezone-aware datetime to float POSIX seconds. Parameters ---------- dt : datetime.datetime or pd.Timestamp A timezone-aware datetime. Returns ------- float Seconds since the Unix epoch (1970-01-01 00:00:00 UTC), including fractional seconds. """ return dt.timestamp()
[docs] def make_index(self, dtstart, until, minutes: int): """Build the regular time index used by ``create_stats`` and ``create_modal_results``. Generates a ``dateutil.rrule`` sequence with step *minutes* from *dtstart* to *until* (inclusive) and returns it in two parallel forms: a list of Berlin-aware ``pd.Timestamp`` objects (for local-time display and slice naming) and a NumPy array of tz-naive UTC ``datetime64[ns]`` values (for xarray database lookup). Parameters ---------- dtstart : datetime-like Start of the iteration range (Berlin-naive or Berlin-aware). until : datetime-like End of the iteration range, inclusive (same timezone convention as *dtstart*). minutes : int Step size in minutes (e.g. 30, 60, 120). Returns ------- aware_list : list of pd.Timestamp Berlin-aware timestamps at every step from *dtstart* to *until*. naive_array : numpy.ndarray of datetime64[ns] Corresponding tz-naive UTC ``datetime64[ns]`` values, suitable for set-difference operations against the database ``time`` coordinate. """ dtstart_dt = pd.Timestamp(dtstart).to_pydatetime() rule = dateutil.rrule.rrule( dateutil.rrule.MINUTELY, interval=minutes, dtstart=dtstart_dt, until=until, cache=True, ) aware_list = [pd.Timestamp(ts, tz=str(self.tz)) for ts in rule] naive_array = np.array( [ts.tz_convert("UTC").tz_localize(None) for ts in aware_list], dtype="datetime64[ns]", ) return aware_list, naive_array
[docs] def is_near_dst_transition(self, t, hours: int = 3) -> bool: """Return ``True`` if *t* is within ±*hours* of any DST transition. Used in ``monitoring.close_to_utc_transition`` to skip files recorded during the ±3-hour window around clock changes, where device timestamps are unreliable. The comparison strips ``tzinfo`` from *t* before comparing against the naive UTC transition times stored in ``pytz``, matching the original behaviour of the inline check this method replaces. Parameters ---------- t : datetime.datetime A datetime object (timezone-aware or timezone-naive). hours : int, optional Half-width of the exclusion window in hours. Defaults to 3. Returns ------- bool ``True`` if *t* falls within *hours* of any DST transition in the project timezone; ``False`` otherwise. """ t_naive = t.replace(tzinfo=None) delta = datetime.timedelta(hours=hours) for utc_transition in self.tz._utc_transition_times[100:106]: if utc_transition - delta < t_naive < utc_transition + delta: return True return False
[docs] def gap_lengths(self, start_times, durations, sample_rates) -> np.ndarray: """Compute gap lengths in samples between consecutive files. Calculates, for each file *i*, how many samples are missing between the end of file *i* and the start of file *i*+1. The last element is always ``np.nan`` because there is no next file. Two correctness issues from the original implementation are fixed here: * **Bug 1 (unit)** — the duration column (float seconds) is multiplied by ``np.timedelta64(1, 's')`` rather than ``'us'``, so 3600 s adds one hour, not 3.6 ms. * **Bug 2 (NaT sentinel)** — ``.shift(time=-1)`` fills the last element with ``NaT``; casting ``NaT`` directly to ``int64`` yields ``INT64_MIN`` (≈ −9.2×10¹⁸), which would propagate as an enormous negative gap. Elements equal to ``INT64_MIN`` are masked to ``np.nan`` before scaling. Parameters ---------- start_times : xarray.DataArray Float64 POSIX start times in **seconds** (the ``start_time`` variable from the file-info database). durations : xarray.DataArray Float64 recording durations in **seconds**. sample_rates : xarray.DataArray Float64 sample rates in Hz. Returns ------- numpy.ndarray Gap lengths in **samples**. The last element is ``np.nan``. Negative values indicate file overlap; zero means perfectly consecutive. """ start_dt = start_times.astype("datetime64[s]") previous_end = start_dt + durations * np.timedelta64(1, "s") shift_start = start_dt.shift(time=-1) int64_min = np.iinfo(np.int64).min shift_s = shift_start.values.astype("int64") end_s = previous_end.values.astype("int64") gap_s = (shift_s - end_s).astype(float) gap_s[shift_s == int64_min] = np.nan return gap_s * sample_rates.values
# Module-level singleton using the project timezone. TC = TimeConvention()