"""Routines to search for maxima and zero crossings.""" from __future__ import print_function, division from numpy import (add, append, argsort, bool_, concatenate, diff, flatnonzero, int8, issubdtype, linspace, multiply, reshape, sign) from .constants import DAY_S EPSILON = 0.001 / DAY_S _trace = None # User can replace with a routine to save search iterations. def find_discrete(start_time, end_time, f, epsilon=EPSILON, num=12): """Find the times at which a discrete function of time changes value. This routine is used to find instantaneous events like sunrise, transits, and the seasons. See :doc:`searches` for how to use it yourself. """ ts = start_time.ts jd0 = start_time.tt jd1 = end_time.tt if jd0 >= jd1: raise ValueError('your start_time {0} is later than your end_time {1}' .format(start_time, end_time)) step_days = getattr(f, 'step_days', None) if step_days is None: # Legacy "rough_period" attribute. rough_period = getattr(f, 'rough_period', None) if rough_period is None: raise AttributeError('the function you have passed' ' is missing a "step_days" attribute') periods = (jd1 - jd0) / rough_period if periods < 1.0: periods = 1.0 sample_count = int(periods * num) else: # Insist on at least 2 samples even if the dates are less than # step_days apart, so the range at least has endpoints. sample_count = int((jd1 - jd0) / step_days) + 2 jd = linspace(jd0, jd1, sample_count) return _find_discrete(ts, jd, f, epsilon, num) # TODO: pass in `y` so it can be precomputed? def _find_discrete(ts, jd, f, epsilon, num): """Algorithm core, for callers that already have a `jd` vector.""" end_mask = linspace(0.0, 1.0, num) start_mask = end_mask[::-1] o = multiply.outer while True: t = ts.tt_jd(jd) y = f(t) indices = flatnonzero(diff(y)) if not len(indices): # Nothing found, so immediately return empty arrays. ends = jd[indices] y = y[indices] break starts = jd[indices] ends = jd[indices + 1] if (ends - starts).max() <= epsilon: y = y[indices + 1] break jd = o(starts, start_mask).flatten() + o(ends, end_mask).flatten() return ts.tt_jd(ends), _fix_numpy_deprecation(y) def find_minima(start_time, end_time, f, epsilon=1.0 / DAY_S, num=12): """Find the local minima in the values returned by a function of time. This routine is used to find events like minimum elongation. See :doc:`searches` for how to use it yourself. """ def g(t): return -f(t) g.rough_period = getattr(f, 'rough_period', None) g.step_days = getattr(f, 'step_days', None) t, y = find_maxima(start_time, end_time, g, epsilon, num) return t, _fix_numpy_deprecation(-y) def find_maxima(start_time, end_time, f, epsilon=1.0 / DAY_S, num=12): """Find the local maxima in the values returned by a function of time. This routine is used to find events like highest altitude and maximum elongation. See :doc:`searches` for how to use it yourself. """ # @@ @@_@@ @@_@@_@@_@@ # / \ / \ / \ # @@ @@ @@ @@ @@ @@ # +1 -1 +1 0 -1 +1 0 0 0 -1 sd = sign(diff(y)) # -2 -1 -1 -1 0 0 -1 diff(sign(diff(y)) ts = start_time.ts jd0 = start_time.tt jd1 = end_time.tt if jd0 >= jd1: raise ValueError('start_time {0} is not earlier than end_time {1}' .format(start_time, end_time)) # We find maxima by investigating every point that is higher than # both points next to it. This presents a problem: if the initial # heights are, for example, [1.7, 1.1, 0.3, ...], there might be a # maximum 1.8 hidden between the first two heights, but it would not # meet the criteria for further investigation because we can't see # whether the curve is on its way up or down to the left of 1.7. So # we put an extra point out beyond each end of our range, then # filter our final result to remove maxima that fall outside the # range. step_days = getattr(f, 'step_days', None) if step_days is None: bump = f.rough_period / num bumps = int((jd1 - jd0) / bump) + 3 jd = linspace(jd0 - bump, jd1 + bump, bumps) else: # Insist on at least 3 samples, even for very close dates; and # add 2 more to stand outside the range. steps = int((jd1 - jd0) / step_days) + 3 real_step = (jd1 - jd0) / steps jd = linspace(jd0 - real_step, jd1 + real_step, steps + 2) end_alpha = linspace(0.0, 1.0, num) start_alpha = end_alpha[::-1] o = multiply.outer while True: t = ts.tt_jd(jd) y = f(t) # Since we start with equal intervals, they all should fall # below epsilon at around the same time; so for efficiency we # only test the first pair. if t[1] - t[0] <= epsilon: jd, y = _identify_maxima(jd, y) # Filter out maxima that fell slightly outside our bounds. keepers = (jd >= jd0) & (jd <= jd1) jd = jd[keepers] y = y[keepers] # Keep only the first of several maxima that are separated # by less than epsilon. if len(jd): mask = concatenate(((True,), diff(jd) > epsilon)) jd = jd[mask] y = y[mask] break left, right = _choose_brackets(y) if _trace is not None: _trace((t, y, left, right)) if not len(left): # No maxima found. jd = y = y[0:0] break starts = jd[left] ends = jd[right] jd = o(starts, start_alpha).flatten() + o(ends, end_alpha).flatten() jd = _remove_adjacent_duplicates(jd) return ts.tt_jd(jd), _fix_numpy_deprecation(y) def _choose_brackets(y): """Return the indices between which we should search for maxima of `y`.""" dsd = diff(sign(diff(y))) indices = flatnonzero(dsd < 0) left = reshape(add.outer(indices, [0, 1]), -1) left = _remove_adjacent_duplicates(left) right = left + 1 return left, right def _identify_maxima(x, y): """Return the maxima we can see in the series y as simple points.""" dsd = diff(sign(diff(y))) # Choose every point that is higher than the two adjacent points. indices = flatnonzero(dsd == -2) + 1 peak_x = x[indices] peak_y = y[indices] # Also choose the midpoint between the edges of a plateau, if both # edges are in view. First we eliminate runs of zeroes, then look # for adjacent -1 values, then map those back to the main array. indices = flatnonzero(dsd) dsd2 = dsd[indices] minus_ones = dsd2 == -1 plateau_indices = flatnonzero(minus_ones[:-1] & minus_ones[1:]) plateau_left_indices = indices[plateau_indices] plateau_right_indices = indices[plateau_indices + 1] + 2 plateau_x = x[plateau_left_indices] + x[plateau_right_indices] plateau_x /= 2.0 plateau_y = y[plateau_left_indices + 1] x = concatenate((peak_x, plateau_x)) y = concatenate((peak_y, plateau_y)) indices = argsort(x) return x[indices], y[indices] def _remove_adjacent_duplicates(a): if not len(a): return a mask = diff(a) != 0 mask = append(mask, [True]) return a[mask] def _fix_numpy_deprecation(y): # Alas, in the future NumPy will apparently disallow using Booleans # as indices, whereas we often encourage the use of `y` as an index. if issubdtype(y.dtype, bool_): y.dtype = int8 return y