Source code for vtools.functions.climatology

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

import numpy as np
import pandas as pd


__all__ = ["climatology", "apply_climatology", "climatology_quantiles"]


HOURS_PER_YEAR = 365 * 24


[docs] def _to_hourly(ts): """Average ts into hourly bins labeled at the start of the hour, [hh:00, hh+1:00). Data that is already hourly is returned unchanged (apart from regularization). Data coarser than hourly raises ValueError. """ if len(ts) > 1: step = pd.Series(ts.index).diff().median() if step > pd.Timedelta("1h"): raise ValueError("Hourly climatology requires data at hourly or finer time step") return ts.resample("h").mean()
[docs] def _hour_of_year(index): """Hour of year (1..8760) on a 365-day calendar for a DatetimeIndex. Feb 29 is mapped onto Feb 28 so that leap years share the 365-day climatology. """ doy = np.asarray(index.dayofyear) leap = np.asarray(index.is_leap_year) doy = np.where(leap & (doy > 59), doy - 1, doy) return (doy - 1) * 24 + np.asarray(index.hour) + 1
[docs] def _circular_mean(values, window, axis): """NaN-aware centered moving average with wraparound along axis. Positions with no valid values in the window remain NaN. """ if window is None or window == 1: return values if window < 1 or window % 2 == 0: raise ValueError("Smoothing windows must be odd positive integers") if window > values.shape[axis]: raise ValueError("Smoothing window is longer than the cycle being smoothed") half = window // 2 valid = ~np.isnan(values) filled = np.where(valid, values, 0.0) total = np.zeros_like(filled) count = np.zeros(filled.shape) for shift in range(-half, half + 1): total += np.roll(filled, shift, axis=axis) count += np.roll(valid, shift, axis=axis) with np.errstate(invalid="ignore", divide="ignore"): return np.where(count > 0, total / count, np.nan)
[docs] def _climatology_hourly(ts, nsmooth, nday): """Hour-of-year climatology (8760 values) with circular smoothing; see climatology.""" if not isinstance(ts.index, pd.DatetimeIndex): raise ValueError("ts must have a DatetimeIndex") hourly = _to_hourly(ts) # Feb 29 is dropped, consistent with the daily climatology hourly = hourly[~((hourly.index.month == 2) & (hourly.index.day == 29))] is_series = isinstance(hourly, pd.Series) frame = hourly.to_frame() if is_series else hourly hoy = _hour_of_year(frame.index) mean = frame.groupby(hoy).mean().reindex(range(1, HOURS_PER_YEAR + 1)) vals = mean.to_numpy(dtype=float) # Pool the same hour of neighboring days, then neighboring hours ncol = vals.shape[1] vals = _circular_mean(vals.reshape(365, 24, ncol), nday, axis=0) vals = _circular_mean(vals.reshape(HOURS_PER_YEAR, ncol), nsmooth, axis=0) out = pd.DataFrame(vals, index=mean.index, columns=mean.columns) out.index.name = "hourofyear" return out.iloc[:, 0].rename(hourly.name) if is_series else out
[docs] def climatology(ts, freq, nsmooth=None, nday=None): """ " Create a climatology on the columns of ts Parameters ---------- ts: DataFrame or Series DataStructure to be analyzed. Must have a length of at least 2*freq freq: period ["hour","day","month"] Period over which the climatology is analyzed. "hour" produces one value for each hour of a 365-day year (8760 values); Feb 29 is ignored. Data are first averaged into hourly bins labeled at the start of the hour, so ts must be hourly or finer. nsmooth: int For "day" and "month": window size (number of values) of pre-smoothing of the input. This may not make sense for series that are not approximately regular. An odd number is usually best. For "hour": odd window size in hours (e.g. 3 for +/- 1 hour) applied to the hourly climatology after averaging across years, wrapping around the end of the year. nday: int Only for "hour". Odd number of days (e.g. 5 for +/- 2 days) over which the same hour of day is pooled to strengthen the estimate, wrapping around the end of the year. None means no pooling across days. Returns: out: DataFrame or Series Data structure of the same type as ts, with Integer index representing month (Jan=1), day of year (1:365) or hour of year (1:8760, Jan 1 00:00 = 1). Hourly values with no data in their window are NaN. """ if freq == "hour": return _climatology_hourly(ts, nsmooth, nday) if nday is not None: raise ValueError("nday is only valid for freq='hour'") if nsmooth is None: ts_mean = ts else: ts_mean = ts.rolling( nsmooth, center=True, min_periods=nsmooth // 2 ).mean() # moving average by = [ts_mean.index.month, ts_mean.index.day] if freq == "month": by = [ts_mean.index.month] elif not (freq == "day"): raise ValueError("invalid frequency, must be 'month', 'day' or 'hour'") mean_data = [] mean_data_size = [] for name, group in ts_mean.groupby(by): if len(by) == 2: (mo, dy) = name if not ((mo == 2) and (dy == 29)): mean_data.append(group.mean(axis=0)) mean_data_size.append(group.count()) else: mean_data.append(group.mean(axis=0)) mean_data_size.append(group.count()) climatology_data = pd.concat(mean_data, axis=1).transpose() indexvals = list( range(1, 13) if freq == "month" else range(1, len(climatology_data) + 1) ) climatology_data.index = list(indexvals) climatology_data.index.name = "month" if freq == "month" else "dayofyear" return climatology_data
[docs] def climatology_quantiles( ts, min_day_year, max_day_year, window_width, quantiles=[0.05, 0.25, 0.5, 0.75, 0.95], ): """ " Create windowed quantiles across years on a time series Parameters ---------- ts: DataFrame or Series DataStructure to be analyzed. min_day_year: int Minimum Julian day to be considered freq: period ["day","month"] Maximum Julian day to be considered window_width: int Number of days to include, including the central day and days on each side. So for instance window_width=15 would span the central date and 7 days on each side quantiles: array-like quantiles requested Returns: out: DataFrame or Series Data structure with Julian day as the index and quantiles as columns. """ if (min_day_year < window_width) or (max_day_year > (365 - window_width)): raise NotImplementedError( "Time brackets that cross January 1 not implemented yet" ) if window_width % 2 == 0: raise ValueError("window_width must be odd") window_half = (window_width - 1) / 2 day_year = ts.index.dayofyear nquant = len(quantiles) clim = pd.DataFrame(columns=quantiles, index=range(min_day_year, max_day_year)) for imid in range(min_day_year, max_day_year): iend = ( imid + window_half + 1 ) # The plus one centers the estimate, equal on each side plus one for center istart = imid - window_half usets = ts[(day_year > istart) & (day_year < iend)] qs = usets.quantile(quantiles) clim.loc[imid, :] = qs.values.flatten() clim.index.name = "day_of_year" return clim
SUBSTEP_OPTIONS = ("fill", "linear", "rhistinterp") _PERIOD_UNIT = {"month": "M", "day": "D", "hour": "h"}
[docs] def _climatology_kind(climate): n = len(climate) if n not in [12, 365, 366, HOURS_PER_YEAR]: raise ValueError("Length of climatology must be 12, 365, 366 or 8760") return {12: "month", HOURS_PER_YEAR: "hour"}.get(n, "day")
[docs] def _fill_climatology(climate, kind, index): """Value of the climatology period (month, day or hour) containing each timestamp.""" if kind == "hour": out = climate.loc[_hour_of_year(index)] out.index = index elif kind == "day": dayofyear = index.dayofyear # Ensure day 366 is handled dayofyear = np.where(dayofyear > 365, 366, dayofyear) out = climate.loc[dayofyear] else: out = climate.loc[index.month] out.index = index return out
[docs] def _interpolate_climatology(climate, kind, index, method, **kwargs): """Treat climatology values as period averages and interpolate them to index.""" unit = _PERIOD_UNIT[kind] # One extra period on each side so that every requested time is interior periods = pd.period_range( index.min().to_period(unit) - 1, index.max().to_period(unit) + 1 ) starts = periods.to_timestamp() values = _fill_climatology(climate, kind, starts) if method == "linear": # period averages are anchored at the middle of their period middles = starts + (((periods + 1).to_timestamp() - starts) / 2) values.index = middles union = middles.union(index) return values.reindex(union).interpolate(method="time").reindex(index) if np.isnan(values.to_numpy(dtype=float)).any(): raise ValueError("rhistinterp requires a climatology with no missing values") from vtools.functions.rhistinterp import rhistinterp values.index = periods out = rhistinterp(values, index, **kwargs) if isinstance(values, pd.Series): out.name = values.name return out
[docs] def apply_climatology( climate, index=None, start=None, end=None, freq=None, substep_interp="fill", **kwargs ): """Apply hourly, daily or monthly climatology to a new index or generate index from start/end/freq Parameters ---------- climate : DataFrame or Series DataFrame with integer index representing month of year (Jan=1), day of year or hour of year. Must be of size 12, 365, 366 or 8760. Day 366 will be inferred from day 365 value. An hourly (8760) climatology uses a 365-day calendar: Feb 29 takes the Feb 28 values. The values are treated as averages over their period (month, day or hour beginning at the label). index : pandas.DatetimeIndex, optional Locations to be inferred. If not provided, must specify start, end, and freq. start : str or datetime-like, optional Start date for generating index (used if index is None). end : str or datetime-like, optional End date for generating index (used if index is None). freq : str, optional Frequency string for generating index (used if index is None). E.g., 'D' for daily, 'M' for monthly. substep_interp : {"fill", "linear", "rhistinterp"}, default "fill" How values are obtained at times within a climatology period (e.g. a 15min index with a daily climatology). - "fill": each timestamp takes the value of the month, day or hour containing it, giving a step function. - "linear": linear interpolation in time between the middles of the periods. - "rhistinterp": rational histospline (see :func:`vtools.rhistinterp`) that preserves the period averages. Requires a climatology without missing values. **kwargs Only for ``substep_interp="rhistinterp"``; passed to :func:`vtools.rhistinterp`. Notably ``p``, the spline tension (default 2.0; larger values are closer to monotonic and strictly positive), and ``lowbound``, a lower bound for the interpolated values. Returns ------- DataFrame or Series Values extracted from climatology for the month, day or hour at the specified index. Notes ----- - If index is not provided, start, end, and freq must be specified to generate a DatetimeIndex using pandas.date_range. """ kind = _climatology_kind(climate) if substep_interp not in SUBSTEP_OPTIONS: raise ValueError( f"substep_interp must be one of {SUBSTEP_OPTIONS}, got {substep_interp!r}" ) if kwargs and substep_interp != "rhistinterp": raise ValueError( f"Unexpected arguments {sorted(kwargs)}: extra keyword arguments are only used with " "substep_interp='rhistinterp'" ) if len(climate) == 365: climate.loc[366, :] = climate.loc[365, :] if index is None: if start is None or end is None or freq is None: raise ValueError("If index is not provided, start, end, and freq must be specified") index = pd.date_range(start=start, end=end, freq=freq) if substep_interp == "fill": return _fill_climatology(climate, kind, index) return _interpolate_climatology(climate, kind, index, substep_interp, **kwargs)
if __name__ == "__main__": import matplotlib.pyplot as plt from dms_datastore.read_ts import read_ts fname = "//cnrastore-bdo/Modeling_Data/continuous_station_repo/raw/des_twi_405_turbidity_*.csv" fname = "//cnrastore-bdo/Modeling_Data/continuous_station_repo/raw/usgs_lib*turbidity*.rdb" selector = "16127_63680" ts = read_ts(fname, selector=selector) window = 19 # central day plus 9 on each side clim = climatology_quantiles(ts, 182, 305, window) clim.plot() plt.show()