Source code for vtools.functions.merge

#!/usr/bin/env python
# -*- coding: utf-8 -*-

__all__ = ["ts_merge", "ts_splice"]

import pandas as pd
import numpy as np
import warnings
from vtools.functions.colname_align import align_inputs_strict
from vtools.data.indexing import resolve_common_freq, regular_index_from_valid_extent


[docs] def _merge_output_index_irregular(series, *, strict_priority): """ Build the output index for ts_merge when frequency is not being preserved. """ if strict_priority: pieces = [] for s in series: lo = s.first_valid_index() hi = s.last_valid_index() if lo is None or hi is None: continue idx = s.index[(s.index >= lo) & (s.index <= hi)] pieces.append(idx) if not pieces: return series[0].index[:0] out = pieces[0] for idx in pieces[1:]: out = out.union(idx, sort=False) return out.sort_values() warnings.warn( "ts_merge(..., preserve_freq=False, strict_priority=False) may produce " "irregular union-index artifacts; this mode is not recommended.", UserWarning, stacklevel=2, ) out = series[0].index for s in series[1:]: out = out.union(s.index, sort=False) return out.sort_values()
[docs] def _build_output_index(series, *, preserve_freq, fallback_builder): """ Decide the output index for a composition routine. Parameters ---------- series : sequence of Series/DataFrame preserve_freq : bool If True, require agreement among all freq-bearing indexes and return a regular target index spanning the global valid-data extent. fallback_builder : callable Called as fallback_builder(series) when preserve_freq does not apply. Returns ------- pandas.Index """ output_freq = resolve_common_freq( [s.index for s in series], preserve_freq=preserve_freq, ) if output_freq is not None: return regular_index_from_valid_extent(series, output_freq) return fallback_builder(series)
#####################
[docs] @align_inputs_strict(seq_arg=0, names_kw="names") def ts_merge(series, names=None, strict_priority=False, preserve_freq=True): """ Merge multiple time series together, prioritizing series in order. Parameters ---------- series : sequence of pandas.Series or pandas.DataFrame Higher priority first. All indexes must be DatetimeIndex. names : None, str, or iterable of str, optional - If `None` (default), inputs must share compatible column names. - If `str`, the output is univariate and will be named accordingly. - If iterable, it is used as a subset/ordering of columns. strict_priority : bool, default False If False (default): lower-priority data may fill NaNs in higher-priority series anywhere (traditional merge/overlay). If True: for each column, within the window [first_valid_index, last_valid_index] of any higher-priority series, lower-priority data are masked out — even if the higher-priority value is NaN. Outside those windows, behavior is unchanged. preserve_freq : bool, default True If True, attempt to preserve a common regular frequency across inputs. When a common regular frequency can be established, the output is reindexed to that regular extent. When it cannot be established, ts_merge falls back to index-based merge behavior, which for strict_priority=True is a patchwise combination of the indexes and for False is a shuffle. Returns ------- pandas.Series or pandas.DataFrame """ # --- Input validation (messages match tests) --- if not isinstance(series, (list, tuple)) or len(series) == 0: raise ValueError("`series` must be a non-empty tuple or list") if not all(isinstance(getattr(s, "index", None), pd.DatetimeIndex) for s in series): raise ValueError("All input series must have a DatetimeIndex.") # If any DataFrame is present, convert Series->DataFrame to unify shapes any_df = any(isinstance(s, pd.DataFrame) for s in series) if any_df: series = [ s.to_frame(name=s.name) if isinstance(s, pd.Series) else s for s in series ] # Column compatibility checks (messages match tests) all_df = all(isinstance(s, pd.DataFrame) for s in series) any_df = any(isinstance(s, pd.DataFrame) for s in series) any_series = any(isinstance(s, pd.Series) for s in series) if all_df: if names is None: cols0 = list(series[0].columns) for s in series[1:]: if list(s.columns) != cols0: raise ValueError( "All input DataFrames must have the same columns when `names` is None." ) elif any_df and any_series: if names is None: df_cols = { c for s in series if isinstance(s, pd.DataFrame) for c in s.columns } for s in series: if isinstance(s, pd.Series) and s.name not in df_cols: raise ValueError( "Mixed Series and DataFrames require Series names to match DataFrame columns." ) # Build output index according to frequency / priority policy original_all_series = all(isinstance(s, pd.Series) for s in series) full_index = _build_output_index( series, preserve_freq=preserve_freq, fallback_builder=lambda seq: _merge_output_index_irregular( seq, strict_priority=strict_priority, ), ) # Align to union index and keep DataFrame shape for column-wise masking aligned = [] for s in series: a = s.reindex(full_index) if isinstance(a, pd.Series): a = a.to_frame(name=a.name) aligned.append(a.copy()) # --- Strict priority dominance windows --- if strict_priority: # For each column, mask lower-priority data only within the dominance window for col in set(c for a in aligned for c in a.columns): # Find the highest-priority series that contains this column for i, hi in enumerate(aligned): if col in hi.columns: s_col = hi[col] lo_idx = s_col.first_valid_index() hi_idx = s_col.last_valid_index() break else: continue # column not present in any input if lo_idx is None or hi_idx is None: continue # no dominance window for this column mask = (full_index >= lo_idx) & (full_index <= hi_idx) for j in range(i + 1, len(aligned)): if col in aligned[j].columns: aligned[j].loc[mask, col] = np.nan # keep numeric dtype # Combine in priority order via combine_first merged = aligned[0] for a in aligned[1:]: merged = merged.combine_first(a) # NEW: ensure the full union index is present (including all-NaN rows) merged = merged.reindex(full_index) # --- keep union, but drop masked lower-only rows inside dominance window --- if strict_priority: # Use the highest-priority input to define the dominance window (per-column union) top = aligned[0] # DataFrame (we normalized earlier) lo_candidates = [top[c].first_valid_index() for c in top.columns] hi_candidates = [top[c].last_valid_index() for c in top.columns] lo0 = min([x for x in lo_candidates if x is not None], default=None) hi0 = max([x for x in hi_candidates if x is not None], default=None) if lo0 is not None and hi0 is not None: idx = merged.index in_window = (idx >= lo0) & (idx <= hi0) in_top_idx = idx.isin( series[0].index ) # keep rows that are from the top series' index # rows that are entirely NaN after strict masking all_nan = ( merged.isna().all(axis=1) if isinstance(merged, pd.DataFrame) else merged.isna() ) # Drop only those that are NaN, within the window, and not in the top index (i.e., lower-only timestamps) drop_mask = in_window & (~in_top_idx) & all_nan if drop_mask.any(): merged = merged.loc[~drop_mask] # If all inputs were univariate Series, return a Series if original_all_series: merged = merged.squeeze() else: if isinstance(merged, pd.Series): merged = merged.to_frame() return merged
[docs] @align_inputs_strict(seq_arg=0, names_kw="names") def ts_splice( series, names=None, transition="prefer_last", floor_dates=False, preserve_freq=True ): """ Splice multiple time series together, prioritizing series in patches of time. Unlike `ts_merge`, which blends overlapping data points, `ts_splice` stitches together time series without overlap. The function determines when to switch between series based on a transition strategy. Parameters ---------- series : tuple or list of pandas.DataFrame or pandas.Series A tuple or list of time series. Each series must have a `DatetimeIndex` and consistent column structure. names : None, str, or iterable of str, optional - If `None` (default), all input series must share common column names, and the output will merge common columns. - If a `str`, all input series must have a **single column**, and the output will be a DataFrame with this name as the column name. - If an iterable of `str`, all input DataFrames must have the same number of columns matching the length of `names`, and these will be used for the output. transition : {'prefer_first', 'prefer_last'} or list of pandas.Timestamp Defines how to determine breakpoints between time series: - `'prefer_first'`: Uses the earlier series on the list during until its valid timestamp. - `'prefer_last'`: Uses the later series starting from its first valid timestamp. - A list of specific timestamps can also be provided as transition points. floor_dates : bool, optional, default=False If `True`, inferred transition timestamps (`prefer_first` or `prefer_last`) are floored to the beginning of the day. This can introduce NaNs if the input series are regular with a `freq` attribute. preserve_freq : bool, default True If True, attempt to preserve a common regular frequency across inputs. When a common regular frequency can be established, the output is reindexed to that regular extent. When it cannot be established, ts_merge falls back to index-based merge behavior, which for strict_priority=True is a patchwise combination of the indexes and for False is a shuffle. Returns ------- pandas.DataFrame or pandas.Series - If the input contains multi-column DataFrames, the output is a DataFrame with the same column structure. - If a collection of single-column `Series` is provided, the output will be a Series. - The output retains a `freq` attribute if all inputs share the same frequency. Notes ----- - The output time index is the union of input time indices. - If `transition` is `'prefer_first'`, gaps may appear in the final time series. - If `transition` is `'prefer_last'`, overlapping data is resolved in favor of later series. See Also -------- ts_merge : Merges series by filling gaps in order of priority. """ series = [s.copy() for s in series] if not isinstance(series, (tuple, list)) or len(series) == 0: raise ValueError( "`series` must be a non-empty tuple or list of pandas.Series or pandas.DataFrame." ) if not all( isinstance(s.index, pd.DatetimeIndex) or isinstance(s.index, pd.PeriodIndex) for s in series ): raise ValueError("All input series must have a DatetimeIndex or PeriodIndex.") # Ensure all input series have the same type of index. index_type = type(series[0].index) if not all(isinstance(s.index, index_type) for s in series): raise ValueError( f"All input series must have indexes of type {index_type.__name__}." ) if transition not in ["prefer_last", "prefer_first"] and not isinstance( transition, list ): raise ValueError( "`transition` must be 'prefer_last', 'prefer_first', or a list of timestamps." ) # Decide whether a regular output index should be imposed at the end. target_index = _build_output_index( series, preserve_freq=preserve_freq, fallback_builder=lambda seq: None, ) # If names is a string, pre-rename each series for consistency. if names is not None and isinstance(names, str): series = [ ( s.rename(columns={s.columns[0]: names}) if isinstance(s, pd.DataFrame) else s.rename(names) ) for s in series ] # Compute transition points. if isinstance(transition, list): transition_points = transition duplicate_keep = "first" else: if transition == "prefer_first": transition_points = [s.last_valid_index() for s in series[:-1]] duplicate_keep = "first" elif transition == "prefer_last": transition_points = [s.first_valid_index() for s in series[1:]] duplicate_keep = "last" if floor_dates: transition_points = [dt.floor("D") for dt in transition_points] transition_points = [None] + transition_points + [None] # Extract sections based on transition points. sections = [] for ts_obj, start, end in zip( series, transition_points[:-1], transition_points[1:] ): section = ts_obj.loc[start:end] if not section.empty: sections.append(section) spliced = pd.concat(sections, axis=0).sort_index() if sections else pd.DataFrame() # If all inputs were univariate, ensure output remains univariate. univariate = all( (s.name is not None if isinstance(s, pd.Series) else s.shape[1] == 1) for s in series ) if univariate and isinstance(spliced, pd.DataFrame): spliced = spliced.iloc[:, 0] # Normalize output type. if all(isinstance(s, pd.Series) for s in series): spliced = spliced.squeeze() else: if isinstance(spliced, pd.Series): spliced = spliced.to_frame() # Remove duplicate timestamps based on transition preference. dup = spliced.index.duplicated(keep=duplicate_keep) if dup.any(): spliced = spliced[~dup] if target_index is not None: spliced = spliced.reindex(target_index) return spliced