Source code for vtools.functions.tidalhours

################################################################
# Three related functions for analyzing tidal cycles from time series data:
# 1. `find_slack(jd, u, leave_mean=False, which='both')`

# - **Purpose:**
#   Identifies the times of "slack water"—the moments when tidal current velocity (`u`) crosses zero (i.e., transitions between ebb and flood tides).
# - **Inputs:**
#   - `jd`: Array of time values (Julian days or similar).
#   - `u`: Array of velocity values (flood-positive).
#   - `leave_mean`: If `False`, removes the mean (low-frequency) component from `u`.
#   - `which`: `'both'`, `'high'`, or `'low'`—specifies which zero-crossings to return.
# - **Process:**
#   - Applies a lowpass filter to smooth `u`.
#   - Optionally removes the mean (longer-term trend).
#   - Interpolates missing values.
#   - Finds indices where `u` crosses zero (from negative to positive or vice versa).
#   - Interpolates to estimate the exact time of zero-crossing.
#   - Determines whether the series starts with ebb or flood.
# - **Returns:**
#   - `jd_slack`: Array of times when slack water occurs.
#   - `start`: String, either `'ebb'` or `'flood'`, indicating the initial state.

# ---

# ### 2. `hour_tide(jd, u=None, h=None, jd_new=None, leave_mean=False)`

# - **Purpose:**
#   Calculates the "tidal hour" for each time point, i.e., the phase of the tidal cycle (0–12, where 0 is slack before ebb).
# - **Inputs:**
#   - `jd`: Array of time values.
#   - `u`: Velocity time series (optional).
#   - `h`: Water level time series (optional).
#   - `jd_new`: Optional new time points to evaluate.
#   - `leave_mean`: See above.
# - **Process:**
#   - If only `h` is given, computes velocity as the time derivative of `h`.
#   - Calls `hour_tide_fn` to get a function mapping time to tidal hour.
#   - Evaluates this function at `jd_new` (or `jd` if not provided).
# - **Returns:**
#   - Array of tidal hour values (0–12) for each time point.

# ---

# ### 3. `hour_tide_fn(jd, u, leave_mean=False)`

# - **Purpose:**
#   Returns a function that computes tidal hour for arbitrary time points, based on the provided time/velocity series.
# - **Inputs:**
#   - `jd`: Time array.
#   - `u`: Velocity array.
#   - `leave_mean`: See above.
# - **Process:**
#   - Finds slack water times using `find_slack`.
#   - Interpolates between slacks to assign a tidal hour (0–12) to each time.
#   - Uses angular interpolation to handle the cyclic nature of the tidal hour.
#   - Returns a function that maps new time points to tidal hour.
# - **Returns:**
#   - Function: `fn(jd_new)` → tidal hour array.

# ---

# **Summary:**
# These functions are used to analyze tidal time series, identifying slack water times and mapping
# any time to its position within the tidal cycle (tidal hour). This is useful for tidal phase analysis
# in estuarine and coastal studies.
#################################################################
import numpy as np
from scipy.interpolate import interp1d
from vtools.functions.filter import cosine_lanczos5
import pandas as pd
from scipy.signal import hilbert
from typing import Union

__all__ = ["find_slack", "hour_tide", "hour_tide_fn", "tidal_hour_signal", "diff_h"]


[docs] def find_slack(jd, u, leave_mean=False, which="both"): # returns ([jd, ...], 'high'|'low') dt = jd[1] - jd[0] dtdelta = pd.Timedelta(dt, unit="D") dayindex = pd.timedelta_range(start=jd[0], periods=len(jd), freq=dtdelta) u_ts = pd.Series(u, index=dayindex) # ~1 hour lowpass u_ts = cosine_lanczos5(u_ts, cutoff_period="1h") if not leave_mean: u_ts -= cosine_lanczos5(u_ts, cutoff_period="16h") u = u_ts.values missing = np.isnan(u) u[missing] = np.interp(jd[missing], jd[~missing], u[~missing]) # transition from ebb/0 to flood, or the other way around sel_low = (u[:-1] <= 0) & (u[1:] > 0) sel_high = (u[:-1] > 0) & (u[1:] <= 0) if which == "both": sel = sel_low | sel_high elif which == "high": sel = sel_high elif which == "low": sel = sel_low else: assert False b = np.nonzero(sel)[0] jd_slack = jd[b] - u[b] / (u[b + 1] - u[b]) * dt if u[0] < 0: start = "ebb" else: start = "flood" return jd_slack, start
[docs] def hour_tide(jd, u=None, h=None, jd_new=None, leave_mean=False, start_datum="ebb"): """ Calculate tide-hour from a time series of u (velocity, flood-positive) or h (water level, i.e. positive-up). jd: time in days, i.e. julian day, datenum, etc. u: velocity, flood-positive h: water level, positive up. jd_new: if you want to evaluate the tidal hour at a different (but overlapping) set of times. leave_mean: by default, the time series mean is removed, but this can be disabled by passing True here. the return values are between 0 and 12, with 0 being slack before ebb (hmm - may need to verify that.). """ assert (u is None) != (h is None), "Must specify one of u,h" if h is not None: # abuse cdiff to avoid concatenate code here dh_dt = cdiff(h) / cdiff(jd) dh_dt[-1] = dh_dt[-2] dh_dt = np.roll(dh_dt, 1) # better staggering to get low tide at h=0 u = dh_dt fn = hour_tide_fn(jd, u, leave_mean=leave_mean, start_datum=start_datum) if jd_new is None: jd_new = jd return fn(jd_new)
[docs] def cdiff(a, n=1, axis=-1): """ Like np.diff, but include difference from last element back to first. """ assert n == 1 # not ready for higher order # assert axis==-1 # also not ready for weird axis result = np.zeros_like(a) d = np.diff(a, n=n, axis=axis) # using [0] instead of 0 means that axis is preserved # so the concatenate is easier last = np.take(a, [0], axis=axis) - np.take(a, [-1], axis=axis) # this is the part where we have to assume axis==-1 # return np.concatenate( [d,last[...,None]] ) return np.concatenate([d, last])
[docs] def hour_tide_fn(jd, u, start_datum="ebb", leave_mean=False): """Return a function for extracting tidal hour from the time/velocity given. Use the _fn version if making repeated calls with different jd_new, but the same jd,u """ # function hr_tide=hour_tide(jd,u,[jd_new],[leave_mean]); # translated from rocky's m-files # generates tidal hours starting at slack water, based on # u is a vector, positive is flood-directed velocity # finds time of "slack" water # unless leave_mean=True, removes low-pass velocity from record jd_slack, start = find_slack(jd, u, leave_mean=leave_mean, which="both") tide_period = 12 # semidiurnal tide period in hours half_tide = tide_period / 2.0 # left/right here allow for one more slack crossing hr_tide = np.interp( jd, jd_slack, np.arange(len(jd_slack)) * half_tide, left=-0.01, right=len(jd_slack) - 0.99, ) if start != start_datum: # if we start on flood while input start_datum is ebb, then we need to shift # the tidal hour by half a tide period. vice versa. hr_tide += half_tide # starting on an ebb # if start=='ebb': # hr_tide += half_tide # starting on an flood # print("start is",start) hr_tide %= tide_period # angular interpolation - have to use scipy interp1d for complex values arg = np.exp(1j * hr_tide * np.pi / half_tide) def fn(jd_new): argi = interp1d(jd, arg, bounds_error=False)(jd_new) hr_tide = (np.angle(argi) * 6 / np.pi) % tide_period return hr_tide return fn
[docs] def tidal_hour_signal2( ts: Union[pd.Series, pd.DataFrame], filter: bool = True ) -> Union[pd.Series, pd.DataFrame]: """ Calculate the tidal hour from a semidiurnal tidal signal. Parameters ---------- ts : pd.Series or pd.DataFrame Input time series of water levels (must have datetime index) filter : bool, optional If True, apply Lanczos filter to remove low-frequency components (default True) Note this is opposite of 'leave_mean' in original implementation Returns ------- pd.Series or pd.DataFrame Tidal hour in datetime format (same shape as input) Notes ----- The tidal hour represents the phase of the semidiurnal tide in temporal units. The calculation uses complex interpolation for smooth phase estimation: 1. The Hilbert transform creates an analytic signal 2. The angle gives the instantaneous phase 3. Complex interpolation avoids phase jumps at 0/2π boundaries 4. This provides continuous phase evolution even during slack tides If h/u distinction is needed, consider applying `diff_h` to separate flood/ebb phases. The derivative was likely included in original code to identify phase reversals during tidal current analysis. """ # Ensure we're working with a DataFrame to handle both Series and DataFrame inputs if isinstance(ts, pd.Series): df = ts.to_frame() return_series = True else: df = ts.copy() return_series = False # Apply filtering if requested (opposite sense of original leave_mean) if filter: filtered = cosine_lanczos5(df, "40h") else: filtered = df # Remove mean if filtered (since cosine_lanczos may preserve it) if filter: detrended = filtered - filtered.mean() else: detrended = filtered # Hilbert transform to get analytic signal # analytic_signal = detrended + 1j * hilbert(detrended) analytic_signal = hilbert(detrended, axis=0) # Get instantaneous phase (angle) - using complex interpolation phase = np.unwrap(np.angle(analytic_signal)) # Convert phase to tidal hours (semidiurnal cycle = 12.42 hours) tidal_period_hours = 12.4206 tidal_hours = (phase * tidal_period_hours / (2 * np.pi)) % tidal_period_hours # Convert to timedelta and add to original index result = pd.to_timedelta(tidal_hours, unit="h") + df.index # Return same type as input if return_series: return result.iloc[:, 0] return result
[docs] def tidal_hour_signal(ts, filter=True): """ Compute the tidal hour of a semidiurnal signal. This function returns the instantaneous phase-based tidal hour for a time series, assuming a semidiurnal signal. Optionally applies a cosine Lanczos low-pass filter (e.g., 40h) to isolate tidal components from subtidal or noisy fluctuations. The tidal hour is computed using the phase of the analytic signal obtained via the Hilbert transform. This phase is then scaled to range from 0 to 12 hours to represent one semidiurnal tidal cycle. The output is a pandas Series aligned with the input time index. Parameters ---------- ts : pandas.Series Time series of water level or other semidiurnal signal. Must have a datetime index. filter : bool, default True Whether to apply a 40-hour cosine Lanczos filter to the input signal. If False, uses the raw signal. Returns ------- pandas.Series Tidal hour as a float (range [0, 12)), indexed by datetime. Notes ----- The tidal hour is derived from the instantaneous phase of the analytic signal. This signal is computed as: `analytic_signal = ts + 1j * hilbert(ts)` The phase (angle) of this complex signal varies smoothly over time and reflects the oscillatory nature of the tide, allowing us to construct a continuous representation of "tidal time" even between extrema. The use of the Hilbert transform provides a smooth interpolation of the signal's phase progression, since it yields the narrow-band envelope and instantaneous phase of the dominant frequency component (assumed to be semidiurnal here). See Also -------- diff_h : Compute the derivative (rate of change) of tidal hour. cosine_lanczos : External function used to apply low-pass filtering. """ if not isinstance(ts, pd.Series): raise ValueError("Input `ts` must be a pandas Series.") if filter: ts -= cosine_lanczos5(ts, cutoff_period="40h") analytic = ts + 1j * hilbert(ts) phase = np.angle(analytic) # Shift from [-π, π] to [0, 2π] phase = (phase + 2 * np.pi) % (2 * np.pi) # Map phase in [0, 2π) to tidal hour in [0, 12) tidal_hour = (phase / (2 * np.pi)) * 12 return pd.Series(tidal_hour, index=ts.index, name="tidal_hour")
[docs] def diff_h(tidal_hour_series): """ Compute the time derivative of tidal hour. This derivative is often included to capture how rapidly the tidal phase is changing, which can be important in modeling flow reversals, estuarine dynamics, or for detecting slack tide conditions where the rate of change is near zero. Parameters ---------- tidal_hour_series : pandas.Series Output of `tidal_hour_signal`, indexed by datetime. Returns ------- pandas.Series Time derivative of tidal hour (dH/dt) in hours/hour, indexed by datetime. """ return ( tidal_hour_series.diff() / tidal_hour_series.index.to_series().diff().dt.total_seconds().div(3600) )