Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@ _version.py
core_typemap.pkl

venv/
uv.lock
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added
- Added 'target_tables' kwarg to DynamicTable subclasses to allow classes that extend DynamicTable subclasses to specify the mapping of DynamicTableRegion columns to the target tables. @rly, @stephprince [#2096](https://github.com/NeurodataWithoutBorders/pynwb/issues/2096)
- Added `get_starting_time()` and `get_duration()` methods to `TimeSeries` to get the starting time and duration of the time series. @h-mayorquin [#2146](https://github.com/NeurodataWithoutBorders/pynwb/pull/2146)
- Added `get_starting_time()` and `get_duration()` methods to `TimeIntervals` to get the earliest start time and total duration (span from earliest start to latest stop) of all intervals. @h-mayorquin [#2146](https://github.com/NeurodataWithoutBorders/pynwb/pull/2146)

### Fixed
- Fixed incorrect warning for path not ending in `.nwb` when no path argument was provided. @t-b [#2130](https://github.com/NeurodataWithoutBorders/pynwb/pull/2130)
Expand Down
56 changes: 56 additions & 0 deletions src/pynwb/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,62 @@ def get_timestamps(self):
else:
return np.arange(len(self.data)) / self.rate + self.starting_time

def get_starting_time(self):
"""
Get the starting time of this TimeSeries in seconds.

Returns
-------
float or None
The starting time in seconds, or None if no starting time is defined
and there is no data.
"""
if self.starting_time is not None:
return self.starting_time
elif self.num_samples is not None and self.num_samples > 0:
return float(self.timestamps[0])
else:
# No starting_time defined and no data (e.g., empty timestamps-based TimeSeries)
return None

def get_duration(self):
"""
Get the duration of this TimeSeries in seconds.

Returns the time span from the first sample to the last sample.
For a single sample or empty TimeSeries with a defined starting_time, returns 0.

Returns
-------
float or None
The duration in seconds, or None if the TimeSeries has no data and
no starting_time defined.

Notes
-----
For rate-based TimeSeries: duration = (n - 1) / rate
For timestamp-based TimeSeries: duration = timestamps[-1] - timestamps[0]

The duration represents the time span between sample times, not the total
recording time. If you need to account for the last sample's duration
(e.g., for continuous recordings), add 1/rate manually.
"""
if self.num_samples is None or self.num_samples == 0:
# Empty TimeSeries with a starting_time has duration 0
if self.starting_time is not None:
return 0.0
return None

if self.num_samples == 1:
return 0.0

if self.fields.get('timestamps'):
timestamps = self.timestamps
return float(timestamps[-1] - timestamps[0])
else:
# Rate-based
return (self.num_samples - 1) / self.rate

def get_data_in_units(self):
"""
Get the data of this TimeSeries in the specified unit of measurement, applying the conversion factor and offset:
Expand Down
38 changes: 38 additions & 0 deletions src/pynwb/epoch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from bisect import bisect_left

import numpy as np

from hdmf.data_utils import DataIO
from hdmf.common import DynamicTable
from hdmf.utils import docval, getargs, popargs, get_docval, AllowPositional
Expand Down Expand Up @@ -81,3 +83,39 @@ def __calculate_idx_count(self, start_time, stop_time, ts_data):
count = stop_idx - start_idx
idx_start = start_idx
return int(idx_start), int(count)

def get_starting_time(self):
"""
Get the earliest start time across all intervals in this TimeIntervals table.

Returns
-------
float or None
The earliest start time in seconds, or None if the table is empty.
"""
if len(self) == 0:
return None
# NOTE: Could be optimized to self['start_time'].data[0] if intervals are guaranteed sorted
return float(np.min(self['start_time'].data[:]))

def get_duration(self):
"""
Get the total duration from the earliest start time to the latest stop time.

Returns
-------
float or None
The duration in seconds, or None if the table is empty.

Notes
-----
The duration represents the time span from the earliest interval start to the
latest interval stop, not the sum of individual interval durations.
"""
if len(self) == 0:
return None
import numpy as np
starting_time = self.get_starting_time()
# NOTE: Could be optimized to self['stop_time'].data[-1] if intervals are guaranteed sorted
stopping_time = float(np.max(self['stop_time'].data[:]))
return stopping_time - starting_time
50 changes: 50 additions & 0 deletions tests/unit/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,56 @@ def test_timestamps_data_length_warning_construct_mode(self):
# the test later on.
obj._in_construct_mode = False

def test_get_starting_time_with_rate(self):
"""Test get_starting_time with rate-based TimeSeries"""
ts = mock_TimeSeries(data=[1, 2, 3], rate=10.0, starting_time=5.0)
self.assertEqual(ts.get_starting_time(), 5.0)

def test_get_starting_time_with_timestamps(self):
"""Test get_starting_time with timestamp-based TimeSeries"""
ts = mock_TimeSeries(data=[1, 2, 3], timestamps=[2.5, 3.5, 4.5])
self.assertEqual(ts.get_starting_time(), 2.5)

def test_get_starting_time_empty_data_with_starting_time(self):
"""Test get_starting_time with empty data but defined starting_time"""
ts = mock_TimeSeries(data=[], rate=10.0, starting_time=5.0)
self.assertEqual(ts.get_starting_time(), 5.0)

def test_get_starting_time_empty_data_no_starting_time(self):
"""Test get_starting_time with empty data and no starting_time returns None"""
ts = mock_TimeSeries(data=[], timestamps=[])
self.assertIsNone(ts.get_starting_time())

def test_get_duration_with_rate(self):
"""Test get_duration with rate-based TimeSeries"""
ts = mock_TimeSeries(data=[1, 2, 3, 4, 5], rate=10.0)
# (5-1) samples at 10 Hz = 4/10 = 0.4 seconds
self.assertEqual(ts.get_duration(), 0.4)

def test_get_duration_with_timestamps(self):
"""Test get_duration with timestamp-based TimeSeries"""
ts = mock_TimeSeries(data=[1, 2, 3, 4], timestamps=[0.0, 1.0, 2.0, 3.0])
# Duration from 0 to 3 seconds = 3 seconds
self.assertEqual(ts.get_duration(), 3.0)

def test_get_duration_single_sample(self):
"""Test get_duration with single sample returns 0"""
ts_rate = mock_TimeSeries(data=[1], rate=10.0)
self.assertEqual(ts_rate.get_duration(), 0.0)

ts_timestamps = mock_TimeSeries(data=[1], timestamps=[5.0])
self.assertEqual(ts_timestamps.get_duration(), 0.0)

def test_get_duration_empty_data_with_starting_time(self):
"""Test get_duration with empty data but defined starting_time returns 0"""
ts = mock_TimeSeries(data=[], rate=10.0, starting_time=5.0)
self.assertEqual(ts.get_duration(), 0.0)

def test_get_duration_empty_data_no_starting_time(self):
"""Test get_duration with empty data and no starting_time returns None"""
ts = mock_TimeSeries(data=[], timestamps=[])
self.assertIsNone(ts.get_duration())


class TestImage(TestCase):
def test_init(self):
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/test_epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,43 @@ def test_from_dataframe_missing_supplied_col(self):
df = pd.DataFrame({'start_time': [1., 2., 3.], 'stop_time': [2., 3., 4.], 'label': ['a', 'b', 'c']})
with self.assertRaises(ValueError):
TimeIntervals.from_dataframe(df, name='ti_name', columns=[{'name': 'not there'}])

def test_get_starting_time(self):
"""Test get_starting_time returns the earliest start time"""
ti = TimeIntervals(name='ti_name')
ti.add_interval(start_time=5.0, stop_time=10.0)
ti.add_interval(start_time=2.0, stop_time=7.0)
ti.add_interval(start_time=8.0, stop_time=12.0)
self.assertEqual(ti.get_starting_time(), 2.0)

def test_get_starting_time_empty_table(self):
"""Test get_starting_time returns None for empty table"""
ti = TimeIntervals(name='ti_name')
self.assertIsNone(ti.get_starting_time())

def test_get_starting_time_single_interval(self):
"""Test get_starting_time with single interval"""
ti = TimeIntervals(name='ti_name')
ti.add_interval(start_time=3.5, stop_time=7.5)
self.assertEqual(ti.get_starting_time(), 3.5)

def test_get_duration(self):
"""Test get_duration returns span from earliest start to latest stop"""
ti = TimeIntervals(name='ti_name')
ti.add_interval(start_time=2.0, stop_time=5.0)
ti.add_interval(start_time=7.0, stop_time=10.0)
ti.add_interval(start_time=12.0, stop_time=18.0)
# Duration from earliest start (2.0) to latest stop (18.0) = 16.0
self.assertEqual(ti.get_duration(), 16.0)

def test_get_duration_empty_table(self):
"""Test get_duration returns None for empty table"""
ti = TimeIntervals(name='ti_name')
self.assertIsNone(ti.get_duration())

def test_get_duration_single_interval(self):
"""Test get_duration with single interval"""
ti = TimeIntervals(name='ti_name')
ti.add_interval(start_time=5.0, stop_time=10.0)
# Duration: 10.0 - 5.0 = 5.0
self.assertEqual(ti.get_duration(), 5.0)
Loading