Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/style-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/unit-tests-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/upload-to-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
4 changes: 4 additions & 0 deletions datkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
2 changes: 1 addition & 1 deletion datkit/_datkit_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__])
Expand Down
101 changes: 93 additions & 8 deletions datkit/_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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`).
Expand Down
90 changes: 90 additions & 0 deletions datkit/tests/test_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading