diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index edf362e..894e342 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -16,12 +16,12 @@ jobs: if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Configure Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' cache: 'pip' cache-dependency-path: setup.py diff --git a/.github/workflows/style-test.yml b/.github/workflows/style-test.yml index 5352fb8..37af5ff 100644 --- a/.github/workflows/style-test.yml +++ b/.github/workflows/style-test.yml @@ -16,12 +16,12 @@ jobs: if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Configure Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' cache: 'pip' cache-dependency-path: setup.py diff --git a/.github/workflows/unit-tests-ubuntu.yml b/.github/workflows/unit-tests-ubuntu.yml index d12a950..6a23206 100644 --- a/.github/workflows/unit-tests-ubuntu.yml +++ b/.github/workflows/unit-tests-ubuntu.yml @@ -16,13 +16,13 @@ jobs: if: github.event.pull_request.draft == false strategy: matrix: - python-version: ['3.7'] + python-version: ['3.8'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Configure Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.github/workflows/upload-to-pypi.yml b/.github/workflows/upload-to-pypi.yml index 55e69c6..1988312 100644 --- a/.github/workflows/upload-to-pypi.yml +++ b/.github/workflows/upload-to-pypi.yml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' - name: install dependencies run: | diff --git a/datkit/__init__.py b/datkit/__init__.py index 56cd520..624d199 100644 --- a/datkit/__init__.py +++ b/datkit/__init__.py @@ -49,16 +49,20 @@ from ._points import ( # noqa abs_max_on, + data_on, iabs_max_on, imax_on, imin_on, index, + index_crossing, index_near, index_on, max_on, mean_on, min_on, + time_crossing, value_at, + value_interpolated, value_near, ) diff --git a/datkit/_datkit_version.py b/datkit/_datkit_version.py index 741f447..b1533cd 100644 --- a/datkit/_datkit_version.py +++ b/datkit/_datkit_version.py @@ -11,7 +11,7 @@ # incompatibility # - Changes to revision indicate bugfixes, tiny new features # - There is no significance to odd/even numbers -__version_tuple__ = 0, 0, 3 +__version_tuple__ = 0, 1, 0 # String version of the version number __version__ = '.'.join([str(x) for x in __version_tuple__]) diff --git a/datkit/_points.py b/datkit/_points.py index bcef1ee..ea3af00 100644 --- a/datkit/_points.py +++ b/datkit/_points.py @@ -22,6 +22,18 @@ def abs_max_on(times, values, t0=None, t1=None, include_left=True, return times[i], values[i] +def data_on(times, values, t0=None, t1=None, include_left=True, + include_right=False): + """ + Returns a tuple ``(times2, values2)`` corresponding to the interval from + ``t0`` to ``t1`` in ``times``. + + See also :meth:`index_on`. + """ + i, j = index_on(times, t0, t1, include_left, include_right) + return times[i:j], values[i:j] + + def iabs_max_on(times, values, t0=None, t1=None, include_left=True, include_right=False): """ @@ -60,7 +72,8 @@ def imin_on(times, values, t0=None, t1=None, include_left=True, def index(times, t, ttol=1e-9): """ - Returns the index of time ``t`` in ``times``. + Returns the index of time ``t`` in ``times``, assuming ``times`` is a + non-decreasing sequence. A ``ValueError`` will be raised if time ``t`` cannot be found in ``times``. Two times will be regarded as equal if they are within ``ttol`` of each @@ -87,10 +100,41 @@ def index(times, t, ttol=1e-9): return i +def index_crossing(values, value=0): + """ + Returns the lowest two indices ``i`` and ``j`` for which ``values`` crosses + the given ``value`` (going either from below to above, or vice versa). + + For example ``datkit.index([0, 1, 2, 3, 4], 2.5)`` returns ``(2, 3)``. + + The method is best applied to smooth (denoised) data. + + A ``ValueError`` is raised if no crossing can be found, or + """ + # Get sign of values - value, as either -1, 0, or 1 + # This means we can't use numpy's sign function. + v = np.asarray(values) - value + s = np.zeros(v.shape) + s[v > 0] = 1 + s[v < 0] = -1 + # Find first non-zero + i = np.where(s != 0)[0] + if len(i) > 0: + i = i[0] + # Find first opposing sign + j = np.where(s == -s[i])[0] + if len(j) > 0: + j = j[0] + # Find last value with original sign + i = np.where(s[:j] == s[i])[0][-1] + return i, j + raise ValueError(f'No crossing of {value} found in array.') + + def index_near(times, t): """ Returns the index of time ``t`` in ``times``, or the index of the nearest - value to it. + value to it, assuming ``times`` is a non-decreasing sequence. If ``t`` is outside the range of ``times`` by more than half a sampling interval (as returned by :meth:`datkit.sampling_interval`), a @@ -118,7 +162,7 @@ def index_near(times, t): def index_on(times, t0=None, t1=None, include_left=True, include_right=False): """ Returns a tuple ``(i0, i1)`` corresponding to the interval from ``t0`` to - ``t1`` in ``times``. + ``t1`` in ``times``, assuming ``times`` is a non-decreasing sequence. By default, the interval is taken as ``t0 <= times < t1``, but this can be customized using ``include_left`` and ``include_right``. @@ -185,20 +229,61 @@ def min_on(times, values, t0=None, t1=None, include_left=True, return times[i], values[i] +def time_crossing(times, values, value=0): + """ + Returns the time at which ``values`` first crosses ``value``. + + Specifically, the method linearly interpolates between the entries from + ``times`` at the indices returned by :meth:`index_crossing`. No assumptions + are made about ``times`` (other than that it has the same length as + ``values``), so that arrays representing other quantities can also be + passed in. + + The method is best applied to smooth (denoised) data. + + See also :meth:`index_crossing`. + """ + i, j = index_crossing(values, value) + t0, t1 = times[i], times[j] + v0, v1 = values[i] - value, values[j] - value + return t0 - v0 * (t1 - t0) / (v1 - v0) + + def value_at(times, values, t, ttol=1e-9): """ - Returns the value at the given time point. + Returns ``values[i]`` such that ``times[i]`` is within ``ttol`` of the time + ``t``. - A ``ValueError`` will be raised if time ``t`` cannot be found in ``times``. - Two times will be regarded as equal if they are within ``ttol`` of each - other. + A ``ValueError`` will be raised if no such ``i`` can be found. """ return values[index(times, t, ttol=ttol)] +def value_interpolated(times, values, t): + """ + Returns the value at the given time, obtained by linear interpolation if + ``t`` is not presesnt in ``times``. + + A ``ValueError`` is raised if no ``i`` can be found such that + ``times[i] <= t <= times[i + 1]``. + """ + i = np.searchsorted(times, t) + n = len(times) + if n > 0 and i < n and times[i] == t: + return values[i] + if i == 0 or i == n: + raise ValueError( + 'Unable to find entries in times from which to interpolate' + f' for t={t}.') + t0, t1 = times[i - 1], times[i] + v0, v1 = values[i - 1], values[i] + return v0 + (t - t0) * (v1 - v0) / (t1 - t0) + + def value_near(times, values, t): """ - Returns the value nearest the given time point, if present in the data. + Returns ``values[i]`` such that ``times[i]`` is the nearest point to ``t`` + in the data. A ``ValueError`` will be raised if no time near ``t`` can be found in ``times`` (see :meth:`index_near`). diff --git a/datkit/tests/test_points.py b/datkit/tests/test_points.py index 529078a..c7b9d5c 100755 --- a/datkit/tests/test_points.py +++ b/datkit/tests/test_points.py @@ -25,6 +25,15 @@ def test_abs_max_on(self): self.assertEqual(d.abs_max_on(t, v, 1.5, 2), (t[99], v[99])) self.assertEqual(d.abs_max_on(t, v, 1.5, 2, False, True), (2, 1)) + def test_data_on(self): + t = [0, 1, 2, 3, 4, 5, 6, 7] + v = [10, 11, 12, 13, 14, 15, 16, 17] + self.assertEqual(d.data_on(t, v, 3, 5), ([3, 4], [13, 14])) + self.assertEqual(d.data_on(t, v, 4), ([4, 5, 6, 7], [14, 15, 16, 17])) + self.assertEqual(d.data_on(t, v, t1=2), ([0, 1], [10, 11])) + self.assertEqual(d.data_on(t, v, t1=2, include_right=True), + ([0, 1, 2], [10, 11, 12])) + def test_iabs_max_on(self): t = np.linspace(0, 2, 101) v = np.cos(t * np.pi) @@ -115,6 +124,47 @@ def test_index(self): self.assertEqual(d.index(times, 7.3 + 9e-10), 49) self.assertRaisesRegex(ValueError, 'range', d.index, times, 7.3 + 2e-9) + # Any sequence is accepted + self.assertEqual(d.index(tuple(times), 7.3), 49) + + def test_index_crossing(self): + + # Simple test + values = [4, 5, 6, 7, 8, 6, 7, 8, 9] + self.assertEqual(d.index_crossing(values, 6.5), (2, 3)) + self.assertEqual(d.index_crossing(values, 8.5), (7, 8)) + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, values, 1) + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, values, 4) + + # Quadratic and cubic + values = np.linspace(-5, 5, 100)**2 + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, values) + values = (np.linspace(-5, 5, 100) - 3)**3 + self.assertEqual(d.index_crossing(values), (79, 80)) + self.assertTrue(values[79] < 0, values[80] > 0) + values = -(np.linspace(-5, 5, 100) - 2)**3 + self.assertEqual(d.index_crossing(values), (69, 70)) + self.assertTrue(values[69] > 0, values[70] < 0) + + # Annoying case 1: starting or ending at value + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, [4, 5, 6], 4) + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, [4, 4, 4, 5, 6], 4) + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, [4, 5, 6], 6) + self.assertRaisesRegex( + ValueError, 'No crossing', d.index_crossing, [4, 5, 6, 6, 6], 6) + values = [3, 3, 3, 4, 5, 4, 3, 2, 1, 2, 3, 3, 3] + self.assertEqual(d.index_crossing(values, 3), (5, 7)) + + # Annoying case 2: being flat at the selected value + values = [9, 9, 8, 7, 6, 5, 5, 5, 5, 4, 3, 2, 2] + self.assertEqual(d.index_crossing(values, 5), (4, 9)) + def test_index_near(self): # Exact matches @@ -138,6 +188,10 @@ def test_index_near(self): self.assertEqual(d.index_near(times, 9.7499), 19) self.assertRaisesRegex(ValueError, 'range', d.index_near, times, 9.751) + # Any sequence is accepted + self.assertEqual(d.index_near(tuple(times), 9.6), 19) + self.assertEqual(d.index_near(list(times), 9.6), 19) + def test_index_on(self): t = np.arange(0, 10) self.assertEqual(d.index_on(t, 2, 4), (2, 4)) @@ -200,6 +254,10 @@ def test_index_on(self): self.assertEqual(d.index_on(t, 3), (2, 10)) self.assertEqual(d.index_on(t, None, 10), (0, 5)) + # Any sequence is accepted + self.assertEqual(d.index_on(tuple(t), 3), (2, 10)) + self.assertEqual(d.index_on(list(t), 3), (2, 10)) + def test_max_on(self): t = np.linspace(0, 2, 101) v = np.cos(t * np.pi) @@ -230,6 +288,20 @@ def test_min_on(self): self.assertEqual(d.min_on(t, v, 1.5, 2), (t[75], v[75])) self.assertEqual(d.min_on(t, v, 1.5, 2, False), (t[76], v[76])) + def test_time_crossing(self): + t = np.linspace(1, 5, 100) + v = np.sin(t) + 1 + self.assertLess(abs(d.time_crossing(t, v, 1) - np.pi), 1e-7) + self.assertRaises(ValueError, d.time_crossing, t, v) + t = np.linspace(0, 5, 100) + self.assertRaises(ValueError, d.time_crossing, t, np.cos(t) - 1) + t, v = [2, 3, 4, 5], [10, 20, 30, 40] + self.assertEqual(d.time_crossing(t, v, 25), 3.5) + self.assertEqual(d.time_crossing(t, v, 31), 4.1) + t, v = [4, 5, 6, 7], [50, 40, 30, 20] + self.assertEqual(d.time_crossing(t, v, 25), 6.5) + self.assertEqual(d.time_crossing(t, v, 31), 5.9) + def test_value_at(self): t = np.arange(0, 10) self.assertEqual(d.value_at(t, t, 0), 0) @@ -239,6 +311,24 @@ def test_value_at(self): self.assertEqual(d.value_at(t, v, 0), 20) self.assertEqual(d.value_at(t, v, 5), 30) + def test_value_interpolated(self): + t, v = [2, 3, 4, 5, 6, 7], [5, 0, 3, -1, 4, 8] + self.assertEqual(d.value_interpolated(t, v, 2), 5) + self.assertEqual(d.value_interpolated(t, v, 4), 3) + self.assertEqual(d.value_interpolated(t, v, 7), 8) + self.assertEqual(d.value_interpolated(t, v, 4.5), 1) + self.assertEqual(d.value_interpolated(t, v, 5.5), 1.5) + self.assertAlmostEqual(d.value_interpolated(t, v, 2.2), 4) + self.assertAlmostEqual(d.value_interpolated(t, v, 6.9), 7.6) + self.assertRaisesRegex(ValueError, 'entries in times', + d.value_interpolated, t, v, 1.9) + self.assertRaisesRegex(ValueError, 'entries in times', + d.value_interpolated, t, v, 7.1) + t, v = [0, 1, 2], [6, 6, 6] + self.assertEqual(d.value_interpolated(t, v, 0), 6) + self.assertEqual(d.value_interpolated(t, v, 1), 6) + self.assertEqual(d.value_interpolated(t, v, 2), 6) + def test_value_near(self): t = np.arange(0, 10) self.assertEqual(d.value_near(t, t, 0), 0) diff --git a/docs/source/finding_points.rst b/docs/source/finding_points.rst index 480e00e..869978a 100644 --- a/docs/source/finding_points.rst +++ b/docs/source/finding_points.rst @@ -4,16 +4,30 @@ Finding points .. currentmodule:: datkit +Indices and values at specific times +==================================== + .. autofunction:: index .. autofunction:: index_near .. autofunction:: index_on +.. autofunction:: index_crossing + +.. autofunction:: time_crossing + .. autofunction:: value_at .. autofunction:: value_near +.. autofunction:: value_interpolated + +.. autofunction:: data_on + +Averages and extremes +===================== + .. autofunction:: mean_on .. autofunction:: max_on diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b95937 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta"