import numpy as np
import pandas as pd
import numba
from scipy.signal import find_peaks
from vtools.functions.filter import cosine_lanczos
__all__ = [
"get_tidal_hl",
"get_tidal_hh_lh",
"get_tidal_ll_hl",
"get_tidal_amplitude",
"get_tidal_hl_zerocrossing",
"get_tidal_phase_diff",
]
[docs]
def get_smoothed_resampled(
df, cutoff_period="2h", resample_period="1min", interpolate_method="pchip"
):
"""Filter then optionally resample a datetime-indexed DataFrame for tidal peak detection.
The cosine_lanczos filter is applied first at the native resolution to remove
high-frequency noise. The series is then resampled to resample_period if it
differs from the input frequency. Filtering before resampling avoids smoothing
interpolated noise.
Args:
df (DataFrame): A single column dataframe indexed by datetime
cutoff_period (str, optional): cutoff period for cosine lanczos filter. Defaults to '2h'.
resample_period (str, optional): Resample to regular period after filtering.
Pass None or the native frequency to skip resampling. Defaults to '1min'.
interpolate_method (str, optional): interpolation method for resampling. Defaults to 'pchip'.
Returns:
DataFrame: filtered (and optionally resampled) dataframe indexed by datetime
"""
# Filter at native resolution first; resampling before filtering would smooth
# interpolated noise rather than the original signal.
df_filtered = cosine_lanczos(df, cutoff_period)
if resample_period is None:
return df_filtered
native_freq = pd.tseries.frequencies.to_offset(df.index.inferred_freq or df.index.freq)
target_freq = pd.tseries.frequencies.to_offset(resample_period)
if native_freq == target_freq:
return df_filtered
dfb = df_filtered.resample(resample_period).bfill()
df_resampled = df_filtered.resample(resample_period).interpolate(method=interpolate_method)
df_resampled[dfb.iloc[:, 0].isna()] = np.nan
return df_resampled
[docs]
@numba.jit(nopython=True)
def lmax(arr):
"""Local maximum: Returns value only when centered on maximum"""
idx = np.argmax(arr)
if idx == len(arr) / 2:
return arr[idx]
else:
return np.nan
[docs]
@numba.jit(nopython=True)
def lmin(arr):
"""Local minimum: Returns value only when centered on minimum"""
idx = np.argmin(arr)
if idx == len(arr) / 2:
return arr[idx]
else:
return np.nan
[docs]
@numba.jit(nopython=True)
def tlmax(arr):
"""return HH(1) or LH (0)"""
idx = np.argmax(arr) # only the first occurence of the maxima is return
# print(arr,idx)
return idx
[docs]
@numba.jit(nopython=True)
def tlmin(arr):
"""return LL(1) or HL(0)"""
idx = np.argmin(arr)
return idx
[docs]
def periods_per_window(moving_window_size: str, period_str: str) -> int:
"""Number of period size in moving window
Args:
moving_window_size (str): moving window size as a string e.g 7H for 7 hour
period_str (str): period as str e.g. 1T for 1 min
Returns:
int: number of periods in the moving window rounded to an integer
"""
return int(
pd.Timedelta(moving_window_size)
/ pd.to_timedelta(pd.tseries.frequencies.to_offset(period_str))
)
[docs]
def tidal_highs_peaks(df, window="25h", prominence=None):
"""Tidal highs using sequential non-overlapping lunar-day windows and scipy find_peaks.
Partitions the series into sequential windows of length *window* (~25h covers
exactly one lunar day, guaranteeing at most two highs per window for a mixed
semidiurnal tide). Within each window ``scipy.signal.find_peaks`` is used to
locate local maxima; these are ranked so the larger is labelled as the higher-high
and the smaller as the lower-high. Returns the same sparse DataFrame format
as :func:`tidal_highs` (column ``"max"``).
This approach is ~400x faster than the rolling-window method for long series
because it visits each sample once rather than W times (W = window width in samples).
Use it after cosine_lanczos filtering when the signal is known to be smooth.
Args:
df (DataFrame): Single-column DataFrame with a regular DatetimeIndex.
window (str, optional): Length of each sequential window. Defaults to ``'25h'``.
prominence (float or None, optional): Minimum peak prominence passed to
``scipy.signal.find_peaks``. ``None`` (default) applies no prominence
filter; the upstream cosine_lanczos filter makes this safe for smooth inputs.
Returns:
DataFrame: Sparse time series of tidal highs with column ``"max"``, indexed
at the times of detected peaks.
"""
col = df.columns[0]
y = df[col].values
window_td = pd.Timedelta(window)
dt = df.index[1] - df.index[0]
win_samples = int(window_td / dt)
# Minimum inter-peak distance within a window: half a semidiurnal period (~6h)
# prevents picking two peaks within the same tidal cycle half.
min_dist = max(1, int(pd.Timedelta("6h") / dt))
kwargs = {"distance": min_dist}
if prominence is not None:
kwargs["prominence"] = prominence
times = []
values = []
n = len(y)
for i0 in range(0, n, win_samples):
i1 = min(i0 + win_samples, n)
seg = y[i0:i1]
# Skip windows that are entirely NaN
if np.all(np.isnan(seg)):
continue
seg_clean = np.where(np.isnan(seg), -np.inf, seg)
peaks, _ = find_peaks(seg_clean, **kwargs)
for p in peaks:
times.append(df.index[i0 + p])
values.append(y[i0 + p])
result = pd.DataFrame({"max": values}, index=pd.DatetimeIndex(times))
result.index.name = df.index.name
return result
[docs]
def tidal_lows_peaks(df, window="25h", prominence=None):
"""Tidal lows using sequential non-overlapping lunar-day windows and scipy find_peaks.
Mirrors :func:`tidal_highs_peaks` for troughs. See that function's docstring for
the rationale. Returns a sparse DataFrame with column ``"min"``.
Args:
df (DataFrame): Single-column DataFrame with a regular DatetimeIndex.
window (str, optional): Length of each sequential window. Defaults to ``'25h'``.
prominence (float or None, optional): Minimum trough prominence. Defaults to ``None``.
Returns:
DataFrame: Sparse time series of tidal lows with column ``"min"``.
"""
col = df.columns[0]
y = df[col].values
window_td = pd.Timedelta(window)
dt = df.index[1] - df.index[0]
win_samples = int(window_td / dt)
min_dist = max(1, int(pd.Timedelta("6h") / dt))
kwargs = {"distance": min_dist}
if prominence is not None:
kwargs["prominence"] = prominence
times = []
values = []
n = len(y)
for i0 in range(0, n, win_samples):
i1 = min(i0 + win_samples, n)
seg = y[i0:i1]
if np.all(np.isnan(seg)):
continue
seg_clean = np.where(np.isnan(seg), np.inf, seg)
peaks, _ = find_peaks(-seg_clean, **kwargs)
for p in peaks:
times.append(df.index[i0 + p])
values.append(y[i0 + p])
result = pd.DataFrame({"min": values}, index=pd.DatetimeIndex(times))
result.index.name = df.index.name
return result
[docs]
def get_tidal_hh_lh(sh):
"""
return HH(1) or LH (0) based from input tide highs (sh) using rolling window of 2 and tlmax function
"""
sth = sh.rolling(2).apply(tlmax, raw=True)
sth.iloc[0] = (
0 if sth.iloc[1, 0] > 0 else 1
) # fill in the first value based on next value
return sth.iloc[:, 0].map({np.nan: "", 0: "LH", 1: "HH"}).astype(str)
[docs]
def get_tidal_ll_hl(sl):
"""return LL(1) or HL (0) based from input tide lows (sl) using rolling window of 2 and tlmin function
"""
stl = sl.rolling(2).apply(tlmin, raw=True)
stl.iloc[0] = (
0 if stl.iloc[1, 0] > 0 else 1
) # fill in the first value based on next value
return stl.iloc[:, 0].map({np.nan: "", 0: "HL", 1: "LL"}).astype(str)
[docs]
def tidal_highs(df, moving_window_size="7h", method="rolling"):
"""Tidal highs (could be upto two highs in a 25 hr period)
Args:
df (DataFrame): a time series with a regular frequency
moving_window_size (str, optional): moving window size to look for highs within. Defaults to '7h'.
method (str, optional): Peak-detection method. ``'rolling'`` (default) uses a
sliding window with numba-accelerated argmax — robust for noisy signals.
``'find_peaks'`` uses sequential 25h windows with scipy find_peaks — ~400x
faster, suitable for pre-filtered smooth signals.
Returns:
DataFrame: an irregular time series with highs at resolution of df.index
"""
if method == "find_peaks":
return tidal_highs_peaks(df)
period_str = df.index.freqstr
periods = periods_per_window(moving_window_size, period_str)
dfmax = df.rolling(moving_window_size, min_periods=periods).apply(lmax, raw=True)
dfmax = dfmax.shift(periods=-(periods // 2 - 1))
dfmax = dfmax.dropna()
dfmax.columns = ["max"]
return dfmax
[docs]
def tidal_lows(df, moving_window_size="7h", method="rolling"):
"""Tidal lows (could be upto two lows in a 25 hr period)
Args:
df (DataFrame): a time series with a regular frequency
moving_window_size (str, optional): moving window size to look for lows within. Defaults to '7h'.
method (str, optional): Peak-detection method. ``'rolling'`` (default) uses a
sliding window with numba-accelerated argmin — robust for noisy signals.
``'find_peaks'`` uses sequential 25h windows with scipy find_peaks — ~400x
faster, suitable for pre-filtered smooth signals.
Returns:
DataFrame: an irregular time series with lows at resolution of df.index
"""
if method == "find_peaks":
return tidal_lows_peaks(df)
period_str = df.index.freqstr
periods = periods_per_window(moving_window_size, period_str)
dfmin = df.rolling(moving_window_size, min_periods=periods).apply(lmin, raw=True)
dfmin = dfmin.shift(periods=-(periods // 2 - 1))
dfmin = dfmin.dropna()
dfmin.columns = ["min"]
return dfmin
[docs]
def get_tidal_hl(
df,
cutoff_period="2h",
resample_period="1min",
interpolate_method="pchip",
moving_window_size="7h",
method="rolling",
):
"""Get Tidal highs and lows
Args:
df (DataFrame): A single column dataframe indexed by datetime
cutoff_period (str, optional): cutoff period for cosine lanczos filter. Defaults to '2h'.
resample_period (str, optional): Resample to regular period after filtering.
Pass None to skip resampling and operate at the native input frequency.
Ignored when method='find_peaks'. Defaults to '1min'.
interpolate_method (str, optional): interpolation for resampling. Defaults to 'pchip'.
moving_window_size (str, optional): moving window size for rolling method. Defaults to '7h'.
method (str, optional): Peak-detection method. ``'rolling'`` (default) uses the
numba-accelerated sliding-window approach \u2014 robust for noisy signals.
``'find_peaks'`` filters at native resolution then uses sequential 25h
windows with scipy find_peaks \u2014 ~400x faster for pre-filtered smooth inputs
such as SF tidal records.
Returns:
tuple of DataFrame: Tidal high and tidal low time series
"""
if method == "find_peaks":
# Filter at native resolution; no resampling needed before find_peaks.
dfs = cosine_lanczos(df, cutoff_period)
return tidal_highs(dfs, method="find_peaks"), tidal_lows(dfs, method="find_peaks")
dfs = get_smoothed_resampled(df, cutoff_period, resample_period, interpolate_method)
return tidal_highs(dfs, method="rolling"), tidal_lows(dfs, method="rolling")
get_tidal_hl_rolling = get_tidal_hl # for older refs. #FIXME
[docs]
def get_tidal_amplitude(dfh, dfl):
"""Tidal amplitude given tidal highs and lows
Args:
dfh (DataFrame): Tidal highs time series
dfl (DataFrame): Tidal lows time series
Returns:
DataFrame: Amplitude timeseries, at the times of the low following the high being used for amplitude calculation
"""
dfamp = pd.concat([dfh, dfl], axis=1)
dfamp = dfamp[["min"]].dropna().join(dfamp[["max"]].ffill())
return pd.DataFrame(dfamp["max"] - dfamp["min"], columns=["amplitude"])
[docs]
def get_tidal_amplitude_diff(dfamp1, dfamp2, percent_diff=False, tolerance="4h"):
"""Get the difference of values within +/- 4H of values in the two amplitude arrays
Args:
dfamp1 (DataFrame): Amplitude time series
dfamp2 (DataFrame): Amplitude time series
percent_diff (bool, optional): If true do percent diff. Defaults to False.
Returns:
DataFrame: Difference dfamp1-dfamp2 or % Difference (dfamp1-dfamp2)/dfamp2*100 for values within +/- 4H of each other
"""
dfamp = pd.merge_asof(
dfamp1,
dfamp2,
left_index=True,
right_index=True,
direction="nearest",
tolerance=pd.Timedelta(tolerance),
)
if percent_diff:
dfdiff = 100.0 * (dfamp.iloc[:, 0] - dfamp.iloc[:, 1]) / dfamp.iloc[:, 1]
else:
dfdiff = dfamp.iloc[:, 0] - dfamp.iloc[:, 1]
return pd.DataFrame(dfdiff, columns=["amplitude_diff"])
[docs]
def get_phase_diff(df1, df2, tolerance="4h"):
df1["time"] = df1.index
df2["time"] = df2.index
df21 = pd.merge_asof(
df2,
df1,
left_index=True,
right_index=True,
direction="nearest",
tolerance=pd.Timedelta(tolerance),
)
return (df21["time_x"] - df21["time_y"]).apply(lambda x: x.total_seconds() / 60)
[docs]
def get_tidal_phase_diff(dfh2, dfl2, dfh1, dfl1, tolerance="4h"):
"""Calculates the phase difference between df2 and df1 tidal highs and lows
Scans +/- 4 hours in df1 to get the highs and lows in that windows for df2 to
get the tidal highs and lows at the times of df1
Args:
dfh2 (DataFrame): Timeseries of tidal highs
dfl2 (DataFrame): Timeseries of tidal lows
dfh1 (DataFrame): Timeseries of tidal highs
dfl1 (DataFRame): Timeseries of tidal lows
Returns:
DataFrame: Phase difference (dfh2-dfh1) and (dfl2-dfl1) in minutes
"""
high_phase_diff = get_phase_diff(dfh2, dfh1, tolerance)
low_phase_diff = get_phase_diff(dfl2, dfl1, tolerance)
merged_diff = pd.merge(
pd.DataFrame(high_phase_diff, index=dfh1.index),
pd.DataFrame(low_phase_diff, index=dfl1.index),
how="outer",
left_index=True,
right_index=True,
)
return merged_diff.iloc[:, 0].fillna(merged_diff.iloc[:, 1])
[docs]
def get_tidal_hl_zerocrossing(df, round_to="1min"):
"""
Finds the tidal high and low times using zero crossings of the first derivative.
This works for all situations but is not robust in the face of noise and perturbations in the signal
"""
zc, zi = zerocross(df)
if round_to:
zc = pd.to_datetime(zc).round(round_to)
return zc
[docs]
def zerocross(df):
"""
Calculates the gradient of the time series and identifies locations where gradient changes sign
Returns the time rounded to nearest minute where the zero crossing happens (based on linear derivative assumption)
"""
diffdfv = pd.Series(np.gradient(df[df.columns[0]].values), index=df.index)
indi = np.where((np.diff(np.sign(diffdfv))) & (diffdfv[1:] != 0))[0]
# Find the zero crossing by linear interpolation
zdb = diffdfv[indi].index
zda = diffdfv[indi + 1].index
x = diffdfv.index
y = diffdfv.values
dx = x[indi + 1] - x[indi]
dy = y[indi + 1] - y[indi]
zc = -y[indi] * (dx / dy) + x[indi]
return zc, indi
##---- FUNCTIONS CACHED BELOW THIS LINE PERHAPS TO USE LATER? ---#
[docs]
def where_changed(df):
""" """
diff = np.diff(df[df.columns[0]].values)
wdiff = np.where(diff != 0)[0]
wdiff = np.insert(wdiff, 0, 0) # insert the first value i.e. zero index
return df.iloc[wdiff + 1, :]
[docs]
def where_same(dfg, df):
"""
return dfg only where its value is the same as df for the same time stamps
i.e. the interesection locations with df
"""
dfall = pd.concat([dfg, df], axis=1)
return dfall[dfall.iloc[:, 0] == dfall.iloc[:, 1]].iloc[:, 0]
[docs]
def limit_to_indices(df, si, ei):
return df[(df.index > si) & (df.index < ei)]
[docs]
def filter_where_na(df, dfb):
"""
remove values in df where dfb has na values
"""
dfx = dfb.loc[df.index]
return df.loc[dfx.dropna().index, :]