# vtools/data/indexing.py
import pandas as pd
from vtools.data.vtime import to_timedelta
__all__ = ["resolve_common_freq", "regular_index_from_valid_extent",
"reindex_to_continuous", "infer_freq_robust", "inferred_regular_freq", "compare_regular_freq"]
"""
Index construction and frequency-handling utilities for time series operations.
This module provides low-level helpers for:
- enforcing frequency consistency across multiple time series,
- constructing regular time indexes from valid data extents, and
- reindexing existing series onto continuous grids when possible.
These functions are used by higher-level operations such as merging,
splicing, and blending of time series.
"""
[docs]
def infer_freq_robust(
index, preferred=["h", "15min", "6min", "10min", "h", "d"], **kwargs
):
"""
Infer the frequency of a time series index using multiple strategies.
Attempts to detect the frequency of a DatetimeIndex by trying various
approaches: direct inference on the full index, on subsets at different
positions, and finally by testing against a list of preferred frequencies.
This robust approach handles indices with irregular intervals or gaps better
than pandas' native infer_freq.
Parameters
----------
index : pd.DatetimeIndex
The time series index to analyze.
preferred : list of str, optional
Preferred frequency strings to test as fallback (default: ["h", "15min", "6min", "10min", "h", "d"]).
Each string should be a valid pandas frequency alias.
**kwargs
Additional keyword arguments (currently unused, reserved for future use).
Returns
-------
str
A pandas frequency alias string (e.g., "h", "D", "15min").
Raises
------
ValueError
If all frequency inference attempts fail.
Notes
-----
- Index is rounded to 1-minute resolution before analysis.
- For short indices (< 8 points), direct inference is attempted.
- For longer indices, inference is tried on the last 7 points, then at
75% and 50% positions, then at the start.
- Finally, preferred frequencies are tested by checking if at least 98%
of index values round correctly to that frequency.
"""
index = index.round("1min")
if len(index) < 8:
# not enough to quibble, use the 8 points
try:
f = pd.infer_freq(index)
except ValueError:
f = None
return f
else:
f = pd.infer_freq(index[-7:-1])
if f is None:
index = index.round("1min")
istrt = 3 * len(index) // 4
f = pd.infer_freq(index[istrt : istrt + 7])
if f is None:
# Give it one more shot halfway through
istrt = len(index) // 2
f = pd.infer_freq(index[istrt : istrt + 7])
if f is None:
f = pd.infer_freq(index[0:7])
if f is None:
for p in preferred:
freq = to_timedelta(p)
tester = index.round(p)
diff = abs(index - tester) < (freq / 5)
frac = diff.mean()
if frac > 0.98:
return p
if f is None:
raise ValueError(
"read_ts set to infer frequency, but multiple attempts failed. Set to string to manually "
)
return f
[docs]
def resolve_common_freq(indexes, preserve_freq=True):
"""
Determine a common frequency across a collection of pandas indexes.
This function inspects the `.freq` attribute of each index and, if
frequency preservation is requested, verifies that all non-null
frequency attributes are identical. If so, that frequency is returned.
Otherwise, an error is raised.
Parameters
----------
indexes : sequence of pandas.Index
Sequence of pandas Index objects (typically DatetimeIndex or
PeriodIndex). Each index may or may not have a `.freq` attribute.
preserve_freq : bool, default True
If True, enforce that all indexes with a non-null `.freq` attribute
have identical frequencies. If any mismatch is detected, a
ValueError is raised.
If False, no checking is performed and the function always returns
None.
Returns
-------
freq : pandas offset or None
The common frequency if one can be determined and
`preserve_freq=True`. Returns None if:
- `preserve_freq=False`, or
- no index has a non-null `.freq` attribute.
Raises
------
ValueError
If `preserve_freq=True` and multiple indexes define conflicting
`.freq` attributes.
Notes
-----
- This function relies only on the `.freq` attribute and does not
attempt to infer frequency using `pandas.infer_freq` or the more robust
vtools functions for that purpose.
- It is intended for use in routines that require strict consistency
of sampling intervals across inputs.
See Also
--------
regular_index_from_valid_extent : Construct a regular index once a
common frequency has been established.
"""
freqs = [idx.freq for idx in indexes if getattr(idx, "freq", None) is not None]
if not preserve_freq or not freqs:
return None
first = freqs[0]
for f in freqs[1:]:
if f != first:
raise ValueError(
"Input series have inconsistent frequencies; cannot preserve frequency."
)
return first
[docs]
def regular_index_from_valid_extent(series, freq):
"""
Construct a regular index spanning the valid data extent of input series.
This function creates a regular (fixed-frequency) index that spans
from the earliest first valid timestamp to the latest last valid
timestamp across a collection of time series.
Parameters
----------
series : sequence of pandas.Series or pandas.DataFrame
Input time series objects. Each must have an index of the same
type (DatetimeIndex or PeriodIndex). The index values are used
to determine the overall time extent of valid data.
freq : pandas offset
Frequency to use when constructing the regular index (e.g.,
pandas offset such as Hour, Day, etc.). Typically obtained from
`resolve_common_freq`.
Returns
-------
index : pandas.DatetimeIndex or pandas.PeriodIndex
A regular index spanning from the minimum first valid timestamp
to the maximum last valid timestamp across all input series.
If no valid timestamps are found in any series, an empty index
of the same type as the first input is returned.
Raises
------
ValueError
If the index type is not supported (i.e., not DatetimeIndex or
PeriodIndex).
Notes
-----
- The function uses `first_valid_index()` and `last_valid_index()`
for each series, so leading and trailing NaNs are ignored when
determining the time extent.
- The returned index includes both endpoints.
- No validation is performed to ensure that input series conform
to the specified frequency.
See Also
--------
resolve_common_freq : Determine whether a shared frequency exists
across input indexes.
"""
firsts = [s.first_valid_index() for s in series if s.first_valid_index() is not None]
lasts = [s.last_valid_index() for s in series if s.last_valid_index() is not None]
if not firsts or not lasts:
return series[0].index[:0]
start = min(firsts)
end = max(lasts)
idx0 = series[0].index
if isinstance(idx0, pd.DatetimeIndex):
return pd.date_range(start=start, end=end, freq=freq)
elif isinstance(idx0, pd.PeriodIndex):
return pd.period_range(start=start, end=end, freq=freq)
else:
raise ValueError("Unsupported index type for frequency preservation.")
[docs]
def reindex_to_continuous(result, freq):
"""
Reindex a time series onto a regular grid if possible.
This function attempts to map an existing time series onto a
continuous, fixed-frequency index spanning its full time extent.
If the existing timestamps are not compatible with the target
regular grid, the input is returned unchanged.
Parameters
----------
result : pandas.Series or pandas.DataFrame
Time series to be reindexed. Must have a DatetimeIndex or
PeriodIndex.
freq : pandas offset or None
Target frequency for the regular index. If None, no action is
taken and `result` is returned unchanged.
Returns
-------
out : pandas.Series or pandas.DataFrame
Reindexed time series if all existing timestamps align with
the target regular grid. Otherwise, the original input is
returned unchanged.
Notes
-----
- The function first constructs a regular index from the minimum
to maximum timestamps of `result` using the provided `freq`.
- If any existing timestamps are not present in the constructed
regular index, the function does not reindex and instead clears
the `.freq` attribute (if possible) before returning the original
data.
- This behavior is intentionally conservative to avoid silently
dropping or shifting data.
- When reindexing succeeds:
- Missing timestamps are filled with NaN.
- The `.freq` attribute is set on the resulting index if possible.
Limitations
-----------
- This function assumes that `result.index` is monotonic and
comparable with the generated regular index.
- It does not attempt to infer or repair irregular spacing.
See Also
--------
regular_index_from_valid_extent : Construct a regular index prior
to composition operations.
resolve_common_freq : Determine if a shared frequency can be enforced.
"""
if freq is None:
return result
start = result.index.min()
end = result.index.max()
if isinstance(result.index, pd.DatetimeIndex):
cont = pd.date_range(start=start, end=end, freq=freq)
elif isinstance(result.index, pd.PeriodIndex):
cont = pd.period_range(start=start, end=end, freq=freq)
else:
return result
try:
if not pd.Index(result.index).isin(cont).all():
try:
result.index.freq = None
except ValueError:
pass
return result
except Exception:
return result
result = result.reindex(cont)
try:
result.index.freq = freq
except ValueError:
result.index.freq = None
return result
[docs]
def inferred_regular_freq(ts):
"""
Determine whether a time series has a regular inferred frequency.
This function is a strict checker for already-read time series. It does
not coerce, round, or repair timestamps. It inspects the existing index
and reports whether a regular frequency can be established.
Parameters
----------
ts : pandas.Series or pandas.DataFrame
Time series with a DatetimeIndex or PeriodIndex.
Returns
-------
freq : pandas offset or None
The inferred or attached frequency if the series is regular.
Returns None if the series is not regular.
reason : str
Diagnostic reason code. One of:
- ``"ok"``
- ``"degenerate"``
- ``"not_datetime_like"``
- ``"not_monotonic"``
- ``"duplicates"``
- ``"infer_failed"``
Notes
-----
This checker is intentionally strict and non-repairing. It is intended
for validation and reconcile policy, not ingestion recovery.
Frequency is determined by:
1. Using ``index.freq`` if present.
2. Otherwise trying ``pandas.infer_freq(index)``.
See Also
--------
compare_regular_freq : Compare the inferred regular frequencies of two
time series for reconcile compatibility.
"""
idx = ts.index
if not isinstance(idx, (pd.DatetimeIndex, pd.PeriodIndex)):
return None, "not_datetime_like"
if len(idx) < 2:
return pd.Timedelta(0), "degenerate"
if not idx.is_monotonic_increasing:
return None, "not_monotonic"
if not idx.is_unique:
return None, "duplicates"
freq = getattr(idx, "freq", None)
if freq is not None:
return freq, "ok"
try:
freq = pd.infer_freq(idx)
except Exception:
freq = None
if freq is None:
return None, "infer_failed"
try:
freq = pd.tseries.frequencies.to_offset(freq)
except Exception:
pass
return freq, "ok"
[docs]
def compare_regular_freq(ts_src, ts_dst):
"""
Compare source and destination time series for frequency compatibility.
This function supports reconcile-policy decisions by checking whether two
already-read time series are regular and, if so, whether their inferred
frequencies match.
Parameters
----------
ts_src : pandas.Series or pandas.DataFrame
Source (staged) time series.
ts_dst : pandas.Series or pandas.DataFrame
Destination (repo) time series.
Returns
-------
status : str
Compatibility classification. One of:
- ``"both_regular_same"``
- ``"both_regular_different"``
- ``"src_irregular"``
- ``"dst_irregular"``
- ``"both_irregular"``
reason : str
Human-readable explanation of the classification.
src_freq : pandas offset or None
Inferred source frequency, if regular.
dst_freq : pandas offset or None
Inferred destination frequency, if regular.
Notes
-----
This helper is asymmetric only in naming: ``src`` and ``dst`` are reported
separately because reconcile policy may differ depending on which side is
irregular.
It does not modify the inputs and does not attempt to reconcile or repair
them.
See Also
--------
inferred_regular_freq : Check whether a single time series is regular and
determine its inferred frequency.
"""
src_freq, src_reason = inferred_regular_freq(ts_src)
dst_freq, dst_reason = inferred_regular_freq(ts_dst)
src_ok = src_freq is not None
dst_ok = dst_freq is not None
if src_ok and dst_ok:
if len(ts_src.index) < 2 and len(ts_dst.index) < 2:
return "both_regular_same", "degenerate_single_point", None, None
if src_freq == dst_freq:
return "both_regular_same", f"both regular with matching frequency: {src_freq}", src_freq, dst_freq
return "both_regular_different", f"frequency mismatch: staged={src_freq} repo={dst_freq}", src_freq, dst_freq
if (not src_ok) and (not dst_ok):
return "both_irregular", f"both irregular: staged={src_reason}, repo={dst_reason}", src_freq, dst_freq
if not src_ok:
return "src_irregular", f"staged not regular: {src_reason}", src_freq, dst_freq
return "dst_irregular", f"repo not regular: {dst_reason}", src_freq, dst_freq